summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-08 08:08:53 -0500
committeradamdottv <[email protected]>2025-07-08 08:09:01 -0500
commit662d022a4859ee1c004133559ee42c5f7044dda7 (patch)
tree304b17deceb254d0d70d29e9d46e865b1c4868cf /packages/tui/internal/components
parent9efef03919f99750277a7f15722c24d7c7958224 (diff)
downloadopencode-662d022a4859ee1c004133559ee42c5f7044dda7.tar.gz
opencode-662d022a4859ee1c004133559ee42c5f7044dda7.zip
feat(tui): paste images and pdfs
Diffstat (limited to 'packages/tui/internal/components')
-rw-r--r--packages/tui/internal/components/chat/editor.go93
-rw-r--r--packages/tui/internal/components/textarea/textarea.go26
2 files changed, 82 insertions, 37 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index f32e5c32a..cc31fbef2 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -1,9 +1,12 @@
package chat
import (
+ "encoding/base64"
"fmt"
"log/slog"
+ "os"
"path/filepath"
+ "strconv"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
@@ -15,10 +18,10 @@ import (
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
- "github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
+ "golang.design/x/clipboard"
)
type EditorComponent interface {
@@ -63,6 +66,57 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
+ case tea.PasteMsg:
+ text := string(msg)
+ text = strings.ReplaceAll(text, "\\", "")
+ text, err := strconv.Unquote(`"` + text + `"`)
+ if err != nil {
+ slog.Error("Failed to unquote text", "error", err)
+ m.textarea.InsertRunesFromUserInput([]rune(msg))
+ return m, nil
+ }
+ if _, err := os.Stat(text); err != nil {
+ slog.Error("Failed to paste file", "error", err)
+ m.textarea.InsertRunesFromUserInput([]rune(msg))
+ return m, nil
+ }
+
+ filePath := text
+ ext := strings.ToLower(filepath.Ext(filePath))
+
+ mediaType := ""
+ switch ext {
+ case ".jpg":
+ mediaType = "image/jpeg"
+ case ".png", ".jpeg", ".gif", ".webp":
+ mediaType = "image/" + ext[1:]
+ case ".pdf":
+ mediaType = "application/pdf"
+ default:
+ mediaType = "text/plain"
+ }
+
+ fileBytes, err := os.ReadFile(filePath)
+ if err != nil {
+ slog.Error("Failed to read file", "error", err)
+ m.textarea.InsertRunesFromUserInput([]rune(msg))
+ return m, nil
+ }
+ base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
+ url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
+
+ attachment := &textarea.Attachment{
+ ID: uuid.NewString(),
+ Display: fmt.Sprintf("<%s>", filePath),
+ URL: url,
+ Filename: filePath,
+ MediaType: mediaType,
+ }
+ m.textarea.InsertAttachment(attachment)
+ m.textarea.InsertString(" ")
+ case tea.ClipboardMsg:
+ text := string(msg)
+ m.textarea.InsertRunesFromUserInput([]rune(text))
case dialog.ThemeSelectedMsg:
m.textarea = m.resetTextareaStyles()
m.spinner = createSpinner()
@@ -269,24 +323,29 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
- _, text, err := image.GetImageFromClipboard()
- if err != nil {
- slog.Error(err.Error())
+ imageBytes := clipboard.Read(clipboard.FmtImage)
+ if imageBytes != nil {
+ base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
+ attachment := &textarea.Attachment{
+ ID: uuid.NewString(),
+ Display: "<clipboard-image>",
+ Filename: "clipboard-image",
+ MediaType: "image/png",
+ URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
+ }
+ m.textarea.InsertAttachment(attachment)
+ m.textarea.InsertString(" ")
return m, nil
}
- // if len(imageBytes) != 0 {
- // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
- // attachment := app.Attachment{
- // FilePath: attachmentName,
- // FileName: attachmentName,
- // Content: imageBytes,
- // MimeType: "image/png",
- // }
- // m.attachments = append(m.attachments, attachment)
- // } else {
- m.textarea.InsertString(text)
- // }
- return m, nil
+
+ textBytes := clipboard.Read(clipboard.FmtText)
+ if textBytes != nil {
+ m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
+ return m, nil
+ }
+
+ // fallback to reading the clipboard using OSC52
+ return m, tea.ReadClipboard
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
index 5ff936f17..41a4a3d91 100644
--- a/packages/tui/internal/components/textarea/textarea.go
+++ b/packages/tui/internal/components/textarea/textarea.go
@@ -11,7 +11,6 @@ import (
"slices"
- "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -653,12 +652,12 @@ func (m *Model) SetValue(s string) {
// InsertString inserts a string at the cursor position.
func (m *Model) InsertString(s string) {
- m.insertRunesFromUserInput([]rune(s))
+ m.InsertRunesFromUserInput([]rune(s))
}
// InsertRune inserts a rune at the cursor position.
func (m *Model) InsertRune(r rune) {
- m.insertRunesFromUserInput([]rune{r})
+ m.InsertRunesFromUserInput([]rune{r})
}
// InsertAttachment inserts an attachment at the cursor position.
@@ -730,8 +729,8 @@ func (m Model) GetAttachments() []*Attachment {
return attachments
}
-// insertRunesFromUserInput inserts runes at the current cursor position.
-func (m *Model) insertRunesFromUserInput(runes []rune) {
+// InsertRunesFromUserInput inserts runes at the current cursor position.
+func (m *Model) InsertRunesFromUserInput(runes []rune) {
// Clean up any special characters in the input provided by the
// clipboard. This avoids bugs due to e.g. tab characters and
// whatnot.
@@ -1429,8 +1428,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
switch msg := msg.(type) {
- case tea.PasteMsg:
- m.insertRunesFromUserInput([]rune(msg))
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
@@ -1490,8 +1487,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.CursorDown()
case key.Matches(msg, m.KeyMap.WordForward):
m.wordRight()
- case key.Matches(msg, m.KeyMap.Paste):
- return m, Paste
case key.Matches(msg, m.KeyMap.CharacterBackward):
m.characterLeft(false /* insideLine */)
case key.Matches(msg, m.KeyMap.LinePrevious):
@@ -1512,11 +1507,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.transposeLeft()
default:
- m.insertRunesFromUserInput([]rune(msg.Text))
+ m.InsertRunesFromUserInput([]rune(msg.Text))
}
case pasteMsg:
- m.insertRunesFromUserInput([]rune(msg))
+ m.InsertRunesFromUserInput([]rune(msg))
case pasteErrMsg:
m.Err = msg
@@ -1908,15 +1903,6 @@ func (m *Model) splitLine(row, col int) {
m.row++
}
-// Paste is a command for pasting from the clipboard into the text input.
-func Paste() tea.Msg {
- str, err := clipboard.ReadAll()
- if err != nil {
- return pasteErrMsg{err}
- }
- return pasteMsg(str)
-}
-
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}