diff options
| author | adamdottv <[email protected]> | 2025-07-04 10:29:40 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-07-04 10:55:02 -0500 |
| commit | f9abc7c84f2544f5844d795bf835064114734817 (patch) | |
| tree | 9719b89b2477e53021aad5fe94c5d465d3f5362b /packages/tui/internal/components/chat | |
| parent | 891ed6ebc006703d5a26f89ecc85bd86f9b2133e (diff) | |
| download | opencode-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.go | 104 |
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 = " " |
