返回 DeepSeek-Reasonix
chat_tui_paste.go
根目录 / internal / cli / chat_tui_paste.go
1 package cli
2
3 import (
4 "fmt"
5 "math"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "regexp"
10 "runtime"
11 "strconv"
12 "strings"
13
14 tea "charm.land/bubbletea/v2"
15 "github.com/atotto/clipboard"
16
17 "reasonix/internal/control"
18 "reasonix/internal/provider"
19 "reasonix/internal/secrets"
20 "reasonix/internal/shellparse"
21 )
22
23 // This file holds the chat TUI's paste & image-attachment input layer: folding
24 // long pasted text into a deletable [Pasted text #N] token, turning
25 // dragged/pasted images and file paths into @references, and the clipboard
26 // commands behind them. The composer state it operates on (pastedBlocks /
27 // nextPasteID / pendingPastes) lives on chatTUI; these are split out of
28 // chat_tui.go as a self-contained concern.
29
30 func pastedLineCount(s string) int {
31 if s == "" {
32 return 0
33 }
34 return strings.Count(strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n"), "\n") + 1
35 }
36
37 func foldedPasteLabel(id, lines int) string {
38 return fmt.Sprintf("[Pasted text #%d · %d lines]", id, lines)
39 }
40
41 func renderFoldedPasteBlock(block pastedBlock) string {
42 return fmt.Sprintf("%s\n\n--- Begin %s ---\n%s\n--- End %s ---", block.label, block.label, block.text, block.label)
43 }
44
45 func shouldFoldPastedText(s string) bool {
46 return len([]rune(s)) >= foldedPasteMinChars || pastedLineCount(s) >= foldedPasteMinLines
47 }
48
49 func (m *chatTUI) shouldFoldPaste(s string) bool {
50 return shouldFoldPastedText(s)
51 }
52
53 func (m *chatTUI) insertFoldedPaste(s string) {
54 m.deleteComposerSelection()
55 label := foldedPasteLabel(m.takeNextPasteID(), pastedLineCount(s))
56 m.pastedBlocks = append(m.pastedBlocks, pastedBlock{label: label, text: s})
57 m.input.InsertString(label + " ")
58 }
59
60 // insertImageRef puts a deletable [image #N] token in the input box (mapped to
61 // the saved attachment's @ref, expanded on submit) so a dragged/pasted image is
62 // edited and removed like any other text, not stranded in a separate tray.
63 func (m *chatTUI) insertImageRef(path string) {
64 m.deleteComposerSelection()
65 label := fmt.Sprintf("[image #%d]", m.takeNextPasteID())
66 m.pastedBlocks = append(m.pastedBlocks, pastedBlock{label: label, text: "@" + path, image: true})
67 m.input.InsertString(label + " ")
68 m.growInputToFit()
69 m.updateCompletion()
70 }
71
72 func (m *chatTUI) expandPastedBlocks(displayed string) string {
73 sent := displayed
74 for _, block := range m.pastedBlocks {
75 if !strings.Contains(sent, block.label) {
76 continue
77 }
78 repl := renderFoldedPasteBlock(block)
79 if block.image {
80 repl = block.text
81 }
82 sent = strings.ReplaceAll(sent, block.label, repl)
83 }
84 // Recover orphaned paste labels that lost their block entries during a
85 // session reload. Each label follows the format
86 // [Pasted text #N · M lines]; the original content was expanded into the
87 // transcript the last time it was submitted. Scan the conversation
88 // history for a prior user message carrying the matching Begin/End block
89 // and re-expand from there. Labels with no verified content remain literal.
90 sent = m.recoverOrphanedPasteLabels(sent)
91 return sent
92 }
93
94 var foldedPasteLabelRe = regexp.MustCompile(`\[Pasted text #(\d+) · (\d+) lines\]`)
95
96 // recoverOrphanedPasteLabels restores paste content only at the explicit
97 // resubmission boundary. Persisted history remains byte-stable for prefix-cache
98 // reuse; labels that cannot be proven to belong to a prior expanded user
99 // message are left unchanged rather than rewriting literal user text.
100 func (m *chatTUI) recoverOrphanedPasteLabels(sent string) string {
101 var history []provider.Message
102 if m.ctrl != nil {
103 history = m.ctrl.History()
104 }
105 return recoverOrphanedPasteLabelsFromHistory(sent, m.pastedBlocks, history)
106 }
107
108 func recoverOrphanedPasteLabelsFromHistory(sent string, knownBlocks []pastedBlock, history []provider.Message) string {
109 // Fast path: no label-like tokens at all.
110 if !strings.Contains(sent, "[Pasted text #") {
111 return sent
112 }
113 matches := foldedPasteLabelRe.FindAllString(sent, -1)
114 if len(matches) == 0 {
115 return sent
116 }
117 // Collect the labels we already know about so we only attempt recovery
118 // for genuinely orphaned ones.
119 known := make(map[string]bool, len(knownBlocks))
120 for _, b := range knownBlocks {
121 known[b.label] = true
122 }
123 seen := make(map[string]bool, len(matches))
124 for _, label := range matches {
125 if known[label] || seen[label] || strings.Contains(sent, "--- Begin "+label+" ---") {
126 continue
127 }
128 seen[label] = true
129 var recovered string
130 found := false
131 ambiguous := false
132 for i := len(history) - 1; i >= 0; i-- {
133 if history[i].Role != provider.RoleUser {
134 continue
135 }
136 for _, body := range expandedPasteBodies(history[i].Content, label) {
137 if !found {
138 recovered = body
139 found = true
140 continue
141 }
142 if recovered != body {
143 ambiguous = true
144 break
145 }
146 }
147 if ambiguous {
148 break
149 }
150 }
151 if found && !ambiguous {
152 sent = strings.ReplaceAll(sent, label, renderFoldedPasteBlock(pastedBlock{
153 label: label,
154 text: recovered,
155 }))
156 }
157 }
158 return sent
159 }
160
161 func expandedPasteBodies(content, label string) []string {
162 expectedLines, ok := foldedPasteLineCount(label)
163 if !ok {
164 return nil
165 }
166 beginMarker := "--- Begin " + label + " ---"
167 endMarker := "\n--- End " + label + " ---"
168 var bodies []string
169 for searchFrom := 0; searchFrom < len(content); {
170 beginOffset := strings.Index(content[searchFrom:], beginMarker)
171 if beginOffset < 0 {
172 break
173 }
174 bodyStart := searchFrom + beginOffset + len(beginMarker)
175 if bodyStart >= len(content) || content[bodyStart] != '\n' {
176 searchFrom = bodyStart
177 continue
178 }
179 bodyStart++
180 endSearchFrom := bodyStart
181 foundEnd := false
182 for endSearchFrom < len(content) {
183 endOffset := strings.Index(content[endSearchFrom:], endMarker)
184 if endOffset < 0 {
185 break
186 }
187 bodyEnd := endSearchFrom + endOffset
188 searchFrom = bodyEnd + len(endMarker)
189 body := content[bodyStart:bodyEnd]
190 if body != "" && pastedLineCount(body) == expectedLines {
191 bodies = append(bodies, body)
192 foundEnd = true
193 break
194 }
195 endSearchFrom = searchFrom
196 }
197 if !foundEnd {
198 break
199 }
200 }
201 return bodies
202 }
203
204 func foldedPasteLineCount(label string) (int, bool) {
205 match := foldedPasteLabelRe.FindStringSubmatch(label)
206 if len(match) < 3 || match[0] != label {
207 return 0, false
208 }
209 lines, err := strconv.Atoi(match[2])
210 if err != nil || lines < 1 {
211 return 0, false
212 }
213 return lines, true
214 }
215
216 // nextPasteIDForHistory prevents labels from being reused after resume or
217 // restart. Older sessions may contain duplicate legacy IDs; starting above the
218 // maximum keeps every newly created label unambiguous.
219 func nextPasteIDForHistory(history []provider.Message) int {
220 next, _ := pasteIDStateForHistory(history)
221 return next
222 }
223
224 func pasteIDStateForHistory(history []provider.Message) (int, map[int]struct{}) {
225 next := 1
226 used := make(map[int]struct{})
227 for _, msg := range history {
228 for _, match := range foldedPasteLabelRe.FindAllStringSubmatch(msg.Content, -1) {
229 if len(match) < 2 {
230 continue
231 }
232 id, err := strconv.Atoi(match[1])
233 if err != nil || id < 1 {
234 continue
235 }
236 used[id] = struct{}{}
237 if id >= next && id < math.MaxInt {
238 next = id + 1
239 }
240 }
241 }
242 return next, used
243 }
244
245 func (m *chatTUI) takeNextPasteID() int {
246 if m.ctrl != nil {
247 m.syncPasteIDStateFromHistory(m.ctrl.History())
248 }
249 candidate := m.nextPasteID
250 if candidate < 1 {
251 candidate = 1
252 }
253 if m.usedPasteIDs == nil {
254 m.usedPasteIDs = make(map[int]struct{})
255 }
256 for {
257 if _, used := m.usedPasteIDs[candidate]; !used {
258 m.usedPasteIDs[candidate] = struct{}{}
259 if candidate == math.MaxInt {
260 m.nextPasteID = 1
261 } else {
262 m.nextPasteID = candidate + 1
263 }
264 return candidate
265 }
266 if candidate == math.MaxInt {
267 candidate = 1
268 } else {
269 candidate++
270 }
271 }
272 }
273
274 func (m *chatTUI) syncPasteIDStateFromHistory(history []provider.Message) {
275 next, used := pasteIDStateForHistory(history)
276 if m.usedPasteIDs == nil {
277 m.usedPasteIDs = make(map[int]struct{}, len(used))
278 }
279 for id := range used {
280 m.usedPasteIDs[id] = struct{}{}
281 }
282 if m.nextPasteID < 1 || next > m.nextPasteID {
283 m.nextPasteID = next
284 }
285 }
286
287 func (m *chatTUI) pasteLabelsIn(s string) []string {
288 var labels []string
289 for _, block := range m.pastedBlocks {
290 if strings.Contains(s, block.label) {
291 labels = append(labels, block.label)
292 }
293 }
294 return labels
295 }
296
297 func (m *chatTUI) clearSubmittedPastes() {
298 if len(m.pendingPastes) == 0 {
299 return
300 }
301 submitted := make(map[string]bool, len(m.pendingPastes))
302 for _, label := range m.pendingPastes {
303 submitted[label] = true
304 }
305 kept := m.pastedBlocks[:0]
306 for _, block := range m.pastedBlocks {
307 if !submitted[block.label] {
308 kept = append(kept, block)
309 }
310 }
311 m.pastedBlocks = kept
312 m.pendingPastes = nil
313 }
314
315 func pasteClipboardImage() tea.Cmd {
316 return func() tea.Msg {
317 path, err := control.SaveClipboardImage()
318 return clipboardImageMsg{path: path, err: err}
319 }
320 }
321
322 type clipboardTextPasteMsg struct {
323 text string
324 err error
325 remote bool
326 }
327
328 var readNativeClipboardText = clipboard.ReadAll
329
330 // pasteClipboardText backs the captured-mouse right-click path. Keyboard text
331 // paste still arrives from the terminal as a bracketed tea.PasteMsg; this read
332 // is deliberately text-only so right-click never probes for an image first.
333 func pasteClipboardText() tea.Cmd {
334 return func() tea.Msg {
335 if remoteClipboardSession() {
336 return clipboardTextPasteMsg{remote: true}
337 }
338 text, err := readNativeClipboardText()
339 return clipboardTextPasteMsg{text: text, err: err}
340 }
341 }
342
343 func imagePasteShortcut(keyName, goos string) bool {
344 if goos == "windows" {
345 return keyName == "alt+v"
346 }
347 return keyName == "ctrl+v"
348 }
349
350 func (m *chatTUI) beginClipboardImagePaste() tea.Cmd {
351 if m.clipboardImagePending {
352 return nil
353 }
354 m.clipboardImagePending = true
355 return pasteClipboardImage()
356 }
357
358 var (
359 readTmuxPasteBuffer = readTmuxBuffer
360 readPrimaryPasteSelection = readPrimarySelection
361 newPasteCommand = exec.Command
362 )
363
364 // pasteMiddleClick returns a tea.Cmd that reads from the selection owner for the
365 // current terminal environment and sends the result through the canonical paste
366 // path. tmux normally owns middle-click and pastes its current buffer, but forwards
367 // the event when an application enables mouse reporting; honor that same contract
368 // here instead of unexpectedly switching to the desktop PRIMARY selection.
369 func pasteMiddleClick() tea.Cmd {
370 return func() tea.Msg {
371 if os.Getenv("TMUX") != "" {
372 text, err := readTmuxPasteBuffer()
373 if err != nil || text == "" {
374 return nil
375 }
376 return tea.PasteMsg{Content: text}
377 }
378 if remoteClipboardSession() {
379 return clipboardTextPasteMsg{remote: true}
380 }
381 text, err := readPrimaryPasteSelection()
382 if err != nil || text == "" {
383 return nil
384 }
385 return tea.PasteMsg{Content: text}
386 }
387 }
388
389 // readTmuxBuffer retrieves the current tmux buffer verbatim. The inherited TMUX
390 // environment variable identifies the correct server socket, just as the tmux
391 // client command does for an interactive shell inside the pane.
392 func readTmuxBuffer() (string, error) {
393 cmd := newPasteCommand("tmux", "save-buffer", "-")
394 cmd.Env = secrets.ProcessEnv()
395 out, err := cmd.Output()
396 if err != nil {
397 return "", fmt.Errorf("read tmux paste buffer: %w", err)
398 }
399 return string(out), nil
400 }
401
402 // readPrimarySelection attempts to retrieve text from the PRIMARY selection by
403 // trying wl-paste (Wayland), xclip (X11), and xsel (X11) in order.
404 func readPrimarySelection() (string, error) {
405 // Match the order used by SaveClipboardImage: Wayland tool first, then X11.
406 for _, args := range [][]string{
407 {"wl-paste", "--primary", "--type", "text", "--no-newline"},
408 {"xclip", "-selection", "primary", "-o"},
409 {"xsel", "--output", "--primary"},
410 } {
411 cmd := newPasteCommand(args[0], args[1:]...)
412 cmd.Env = secrets.ProcessEnv()
413 out, err := cmd.Output()
414 if err == nil {
415 return string(out), nil
416 }
417 }
418 return "", fmt.Errorf("no primary selection tool found (need wl-paste, xclip, or xsel)")
419 }
420
421 func (m *chatTUI) attachPastedImages(text string) bool {
422 sources, ok := pastedImageSources(text)
423 if !ok {
424 return false
425 }
426 attached := false
427 for _, src := range sources {
428 path, err := savePastedImageSource(src)
429 if err != nil {
430 m.notice("paste image: " + err.Error())
431 continue
432 }
433 m.insertImageRef(path)
434 attached = true
435 }
436 if !attached && m.validComposerSelection() && !m.composerSel.empty() {
437 // A failed attachment must not replace an active selection. The notice
438 // above explains the failure; callers otherwise fall back to text paste.
439 return true
440 }
441 return attached
442 }
443
444 var markdownImageSourceRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
445
446 type pastedImageSource struct {
447 value string
448 shellDecoded bool
449 }
450
451 func pastedImageSources(text string) ([]pastedImageSource, bool) {
452 return pastedImageSourcesForOS(text, runtime.GOOS)
453 }
454
455 func pastedImageSourcesForOS(text, goos string) ([]pastedImageSource, bool) {
456 trimmed := strings.TrimSpace(text)
457 if trimmed == "" {
458 return nil, false
459 }
460 if isDataImage(trimmed) {
461 return []pastedImageSource{{value: trimmed}}, true
462 }
463 if matches := markdownImageSourceRe.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
464 rest := strings.TrimSpace(markdownImageSourceRe.ReplaceAllString(trimmed, ""))
465 if rest == "" {
466 sources := make([]pastedImageSource, 0, len(matches))
467 for _, m := range matches {
468 sources = append(sources, pastedImageSource{value: m[1]})
469 }
470 return sources, true
471 }
472 }
473
474 lines := nonEmptyPasteLines(trimmed)
475 lineSources := rawPastedImageSources(lines)
476 if len(lines) > 0 && allImageSources(lineSources, goos) {
477 return lineSources, true
478 }
479 fields := splitPastePathTokens(trimmed)
480 fieldSources := rawPastedImageSources(fields)
481 if len(fields) > 1 && allImageSources(fieldSources, goos) {
482 return fieldSources, true
483 }
484 if staticFields, malformed := shellparse.StaticFields(trimmed); malformed == "" && len(staticFields) > 1 {
485 sources := make([]pastedImageSource, 0, len(staticFields))
486 for _, field := range staticFields {
487 sources = append(sources, pastedImageSource{value: field, shellDecoded: true})
488 }
489 if allImageSources(sources, goos) {
490 return sources, true
491 }
492 }
493 return nil, false
494 }
495
496 func rawPastedImageSources(values []string) []pastedImageSource {
497 sources := make([]pastedImageSource, 0, len(values))
498 for _, value := range values {
499 sources = append(sources, pastedImageSource{value: value})
500 }
501 return sources
502 }
503
504 // splitPastePathTokens splits pasted text into path tokens the way a shell
505 // would hand them to a program: unescaped, unquoted whitespace separates
506 // tokens, while backslash escapes and token-leading quotes keep a path with
507 // spaces together. Tokens keep their original escapes/quotes so each one
508 // round-trips through pastedImagePath. Quotes only open at the start of a
509 // token, so an apostrophe inside a word ("it's") never swallows the rest of
510 // the text.
511 func splitPastePathTokens(s string) []string {
512 var tokens []string
513 var b strings.Builder
514 var quote byte
515 escaped := false
516 flush := func() {
517 if b.Len() > 0 {
518 tokens = append(tokens, b.String())
519 b.Reset()
520 }
521 }
522 for i := 0; i < len(s); i++ {
523 ch := s[i]
524 switch {
525 case escaped:
526 b.WriteByte(ch)
527 escaped = false
528 case quote != 0:
529 b.WriteByte(ch)
530 if ch == quote {
531 quote = 0
532 }
533 case ch == '\\':
534 b.WriteByte(ch)
535 escaped = true
536 case (ch == '\'' || ch == '"') && b.Len() == 0:
537 b.WriteByte(ch)
538 quote = ch
539 case ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n':
540 flush()
541 default:
542 b.WriteByte(ch)
543 }
544 }
545 flush()
546 return tokens
547 }
548
549 func nonEmptyPasteLines(text string) []string {
550 var out []string
551 for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
552 line = strings.TrimSpace(line)
553 if line != "" {
554 out = append(out, line)
555 }
556 }
557 return out
558 }
559
560 func allImageSources(sources []pastedImageSource, goos string) bool {
561 if len(sources) == 0 {
562 return false
563 }
564 for _, src := range sources {
565 if !looksLikeImageSource(src, goos) {
566 return false
567 }
568 }
569 return true
570 }
571
572 func looksLikeImageSource(src pastedImageSource, goos string) bool {
573 if isDataImage(strings.TrimSpace(src.value)) {
574 return true
575 }
576 for _, path := range pastedPathCandidates(src.value, goos, src.shellDecoded) {
577 switch strings.ToLower(filepath.Ext(path)) {
578 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
579 return true
580 }
581 }
582 return false
583 }
584
585 func savePastedImageSource(src pastedImageSource) (string, error) {
586 value := strings.TrimSpace(src.value)
587 if isDataImage(value) {
588 return control.SaveImageDataURL(value)
589 }
590 var lastErr error
591 for _, path := range pastedPathCandidates(value, runtime.GOOS, src.shellDecoded) {
592 if !looksLikeImagePath(path) {
593 continue
594 }
595 saved, err := control.SaveImageFile(path)
596 if err == nil {
597 return saved, nil
598 }
599 lastErr = err
600 }
601 if lastErr != nil {
602 return "", lastErr
603 }
604 return "", fmt.Errorf("unsupported pasted image source")
605 }
606
607 func looksLikeImagePath(path string) bool {
608 switch strings.ToLower(filepath.Ext(path)) {
609 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
610 return true
611 default:
612 return false
613 }
614 }
615
616 func isDataImage(src string) bool {
617 return strings.HasPrefix(strings.ToLower(strings.TrimSpace(src)), "data:image/")
618 }
619
620 // pastedImagePathForOS returns the preferred syntactic candidate with the OS
621 // injected so platform-specific path handling is testable everywhere.
622 func pastedImagePathForOS(src, goos string) (string, bool) {
623 candidates := pastedPathCandidates(src, goos, false)
624 if len(candidates) == 0 {
625 return "", false
626 }
627 return candidates[0], true
628 }
629
630 func hasUnescapedPathWhitespace(s string) bool {
631 escaped := false
632 for i := 0; i < len(s); i++ {
633 ch := s[i]
634 if escaped {
635 escaped = false
636 continue
637 }
638 if ch == '\\' {
639 escaped = true
640 continue
641 }
642 if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
643 return true
644 }
645 }
646 return false
647 }
648
649 // unescapeShellPath applies POSIX backslash semantics to an unquoted pasted
650 // path: a backslash makes the next byte literal, whatever it is — zsh and
651 // bash escape any byte they consider special that way (space, parens, ^,
652 // comma, $, ...), so a whitelist would always lag behind. A trailing
653 // backslash stays literal. Quoted paths and Windows paths never reach here.
654 func unescapeShellPath(s string) string {
655 var b strings.Builder
656 b.Grow(len(s))
657 for i := 0; i < len(s); i++ {
658 if s[i] == '\\' && i+1 < len(s) {
659 i++
660 }
661 b.WriteByte(s[i])
662 }
663 return b.String()
664 }
665
666 // pastedFileRef turns a dragged/pasted non-image file path into an @reference so
667 // it attaches instead of landing as literal text (and, for a POSIX path, being
668 // misread as a slash command). Images are handled earlier; only path-shaped
669 // content (a separator) that points at a real file qualifies, so an ordinary
670 // pasted word is left alone. Whitespace in the path is escaped so the ref
671 // survives @-token parsing on submit.
672 func pastedFileRef(content string) (string, bool) {
673 path, ok := resolveExistingPastedPath(content, runtime.GOOS, false, pastedPathExists)
674 if !ok || !strings.ContainsAny(path, `/\`) {
675 return "", false
676 }
677 return "@" + control.EscapeRefPath(path), true
678 }
679
679 lines GO