summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/chat
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-04 10:29:40 -0500
committeradamdottv <[email protected]>2025-07-04 10:55:02 -0500
commitf9abc7c84f2544f5844d795bf835064114734817 (patch)
tree9719b89b2477e53021aad5fe94c5d465d3f5362b /packages/tui/internal/components/chat
parent891ed6ebc006703d5a26f89ecc85bd86f9b2133e (diff)
downloadopencode-f9abc7c84f2544f5844d795bf835064114734817.tar.gz
opencode-f9abc7c84f2544f5844d795bf835064114734817.zip
feat(tui): file attachments
Diffstat (limited to 'packages/tui/internal/components/chat')
-rw-r--r--packages/tui/internal/components/chat/editor.go104
1 files changed, 84 insertions, 20 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 669ef47d0..595fd4d57 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -3,11 +3,14 @@ package chat
import (
"fmt"
"log/slog"
+ "path/filepath"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/google/uuid"
+ "github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
@@ -37,7 +40,6 @@ type EditorComponent interface {
type editorComponent struct {
app *app.App
textarea textarea.Model
- attachments []app.Attachment
spinner spinner.Model
interruptKeyInDebounce bool
}
@@ -66,17 +68,43 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
- if msg.IsCommand {
+ switch msg.ProviderID {
+ case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
- } else {
- existingValue := m.textarea.Value()
+ case "files":
+ atIndex := m.textarea.LastRuneIndex('@')
+ if atIndex == -1 {
+ // Should not happen, but as a fallback, just insert.
+ m.textarea.InsertString(msg.CompletionValue + " ")
+ return m, nil
+ }
- // Replace the current token (after last space)
+ // The range to replace is from the '@' up to the current cursor position.
+ // Replace the search term (e.g., "@search") with an empty string first.
+ cursorCol := m.textarea.CursorColumn()
+ m.textarea.ReplaceRange(atIndex, cursorCol, "")
+
+ // Now, insert the attachment at the position where the '@' was.
+ // The cursor is now at `atIndex` after the replacement.
+ filePath := msg.CompletionValue
+ fileName := filepath.Base(filePath)
+ attachment := &textarea.Attachment{
+ ID: uuid.NewString(),
+ Display: "@" + fileName,
+ URL: fmt.Sprintf("file://%s", filePath),
+ Filename: fileName,
+ MediaType: "text/plain",
+ }
+ m.textarea.InsertAttachment(attachment)
+ m.textarea.InsertString(" ")
+ return m, nil
+ default:
+ existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
@@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) string {
if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
- hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
+ hint = muted(
+ "working",
+ ) + m.spinner.View() + muted(
+ " ",
+ ) + base(
+ keyText+" again",
+ ) + muted(
+ " interrupt",
+ )
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
@@ -195,14 +231,23 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
var cmds []tea.Cmd
+
+ attachments := m.textarea.GetAttachments()
+ fileParts := make([]opencode.FilePartParam, 0)
+ for _, attachment := range attachments {
+ fileParts = append(fileParts, opencode.FilePartParam{
+ Type: opencode.F(opencode.FilePartTypeFile),
+ MediaType: opencode.F(attachment.MediaType),
+ URL: opencode.F(attachment.URL),
+ Filename: opencode.F(attachment.Filename),
+ })
+ }
+
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
- attachments := m.attachments
- m.attachments = nil
-
- cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
+ cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
return m, tea.Batch(cmds...)
}
@@ -212,18 +257,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
- imageBytes, text, err := image.GetImageFromClipboard()
+ _, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
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.SetValue(m.textarea.Value() + text)
- }
+ // 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.SetValue(m.textarea.Value() + text)
+ // }
return m, nil
}
@@ -254,12 +304,26 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
+ ta.Styles.Blurred.Placeholder = styles.NewStyle().
+ Foreground(textMutedColor).
+ Background(bgColor).
+ Lipgloss()
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
+ ta.Styles.Focused.Placeholder = styles.NewStyle().
+ Foreground(textMutedColor).
+ Background(bgColor).
+ Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
+ ta.Styles.Attachment = styles.NewStyle().
+ Foreground(t.Secondary()).
+ Background(bgColor).
+ Lipgloss()
+ ta.Styles.SelectedAttachment = styles.NewStyle().
+ Foreground(t.Text()).
+ Background(t.Secondary()).
+ Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "