opencode/packages/tui/internal/components/chat/editor.go

455 lines
12 KiB
Go
Raw Normal View History

2025-04-12 00:54:13 +08:00
package chat
import (
2025-07-08 21:08:53 +08:00
"encoding/base64"
2025-05-03 04:23:58 +08:00
"fmt"
2025-05-17 03:31:50 +08:00
"log/slog"
2025-07-08 21:08:53 +08:00
"os"
2025-07-04 23:29:40 +08:00
"path/filepath"
2025-07-08 21:08:53 +08:00
"strconv"
"strings"
2025-06-12 18:35:40 +08:00
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
2025-07-04 23:29:40 +08:00
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
2025-06-04 22:20:42 +08:00
"github.com/sst/opencode/internal/app"
2025-06-13 22:57:54 +08:00
"github.com/sst/opencode/internal/commands"
2025-06-04 22:20:42 +08:00
"github.com/sst/opencode/internal/components/dialog"
2025-06-19 21:45:24 +08:00
"github.com/sst/opencode/internal/components/textarea"
2025-06-04 22:20:42 +08:00
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
2025-07-08 21:08:53 +08:00
"golang.design/x/clipboard"
2025-04-12 00:54:13 +08:00
)
type EditorComponent interface {
tea.Model
View(width int) string
Content(width int) string
2025-06-19 21:45:24 +08:00
Lines() int
Value() string
Focused() bool
Focus() (tea.Model, tea.Cmd)
Blur()
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
}
2025-06-06 04:44:20 +08:00
type editorComponent struct {
app *app.App
textarea textarea.Model
spinner spinner.Model
interruptKeyInDebounce bool
exitKeyInDebounce bool
2025-04-12 00:54:13 +08:00
}
2025-06-06 04:44:20 +08:00
func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
2025-04-12 08:01:45 +08:00
}
2025-06-06 04:44:20 +08:00
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
2025-04-12 00:54:13 +08:00
var cmd tea.Cmd
2025-06-19 21:45:24 +08:00
2025-04-12 02:31:24 +08:00
switch msg := msg.(type) {
2025-06-21 00:08:08 +08:00
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
2025-06-19 21:45:24 +08:00
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
2025-07-08 21:08:53 +08:00
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)
2025-07-09 02:02:13 +08:00
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
label := "File"
if strings.HasPrefix(mediaType, "image/") {
label = "Image"
}
2025-07-08 21:08:53 +08:00
attachment := &textarea.Attachment{
ID: uuid.NewString(),
2025-07-09 02:02:13 +08:00
MediaType: mediaType,
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
2025-07-08 21:08:53 +08:00
URL: url,
Filename: filePath,
}
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()
2025-06-17 04:58:46 +08:00
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
2025-07-04 23:29:40 +08:00
switch msg.ProviderID {
case "commands":
2025-06-14 04:56:30 +08:00
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
2025-06-19 21:45:24 +08:00
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...)
2025-07-04 23:29:40 +08:00
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
}
2025-06-27 01:21:15 +08:00
2025-07-04 23:29:40 +08:00
// 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
2025-07-05 00:13:09 +08:00
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
case ".jpg":
mediaType = "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
mediaType = "image/" + extension[1:]
case ".pdf":
mediaType = "application/pdf"
default:
mediaType = "text/plain"
}
2025-07-04 23:29:40 +08:00
attachment := &textarea.Attachment{
ID: uuid.NewString(),
2025-07-05 05:57:48 +08:00
Display: "@" + filePath,
2025-07-05 00:16:55 +08:00
URL: fmt.Sprintf("file://./%s", filePath),
2025-07-05 00:42:22 +08:00
Filename: filePath,
2025-07-05 00:13:09 +08:00
MediaType: mediaType,
2025-07-04 23:29:40 +08:00
}
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 + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
}
2025-06-14 04:56:30 +08:00
return m, nil
}
2025-04-12 02:31:24 +08:00
}
2025-06-06 04:44:20 +08:00
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
2025-04-12 08:01:45 +08:00
m.textarea, cmd = m.textarea.Update(msg)
2025-06-06 04:44:20 +08:00
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
2025-04-12 00:54:13 +08:00
}
func (m *editorComponent) Content(width int) string {
2025-04-28 21:46:09 +08:00
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
promptStyle := styles.NewStyle().Foreground(t.Primary()).
2025-04-28 21:46:09 +08:00
Padding(0, 0, 0, 1).
Bold(true)
2025-06-06 04:44:20 +08:00
prompt := promptStyle.Render(">")
2025-04-12 00:54:13 +08:00
m.textarea.SetWidth(width - 6)
2025-06-06 04:44:20 +08:00
textarea := lipgloss.JoinHorizontal(
lipgloss.Top,
prompt,
m.textarea.View(),
)
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
2025-07-01 19:41:39 +08:00
Width(width).
2025-06-14 00:27:05 +08:00
PaddingTop(1).
PaddingBottom(1).
2025-06-27 01:44:44 +08:00
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
BorderLeft(true).
BorderRight(true).
2025-06-06 04:44:20 +08:00
Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.exitKeyInDebounce {
keyText := m.getExitKeyText()
hint = base(keyText+" again") + muted(" to exit")
} else if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
2025-07-04 23:29:40 +08:00
hint = muted(
"working",
) + m.spinner.View() + muted(
" ",
) + base(
keyText+" again",
) + muted(
" interrupt",
)
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
2025-06-06 04:44:20 +08:00
}
model := ""
if m.app.Model != nil {
2025-06-19 05:09:49 +08:00
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
2025-05-03 04:23:58 +08:00
}
2025-06-06 04:44:20 +08:00
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
2025-06-06 04:44:20 +08:00
2025-06-16 04:07:00 +08:00
info := hint + spacer + model
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
2025-06-06 04:44:20 +08:00
2025-06-16 04:07:00 +08:00
content := strings.Join([]string{"", textarea, info}, "\n")
2025-06-12 18:35:40 +08:00
return content
2025-04-12 00:54:13 +08:00
}
func (m *editorComponent) View(width int) string {
2025-06-19 21:45:24 +08:00
if m.Lines() > 1 {
2025-06-28 19:04:01 +08:00
return lipgloss.Place(
2025-07-01 19:41:39 +08:00
width,
5,
lipgloss.Center,
2025-06-28 19:04:01 +08:00
lipgloss.Center,
"",
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
2025-06-28 19:04:01 +08:00
)
2025-06-19 21:45:24 +08:00
}
return m.Content(width)
2025-06-19 21:45:24 +08:00
}
func (m *editorComponent) Focused() bool {
return m.textarea.Focused()
}
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
return m, m.textarea.Focus()
}
func (m *editorComponent) Blur() {
m.textarea.Blur()
}
2025-06-19 21:45:24 +08:00
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
func (m *editorComponent) Value() string {
2025-06-19 04:51:21 +08:00
return m.textarea.Value()
2025-04-12 00:54:13 +08:00
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
2025-06-19 04:51:21 +08:00
value := strings.TrimSpace(m.Value())
if value == "" {
return m, nil
2025-06-06 04:44:20 +08:00
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
2025-07-05 00:42:22 +08:00
m.textarea.ReplaceRange(len(value)-1, len(value), "")
m.textarea.InsertString("\n")
return m, nil
2025-06-06 04:44:20 +08:00
}
2025-06-19 21:45:24 +08:00
var cmds []tea.Cmd
2025-07-04 23:29:40 +08:00
attachments := m.textarea.GetAttachments()
fileParts := make([]opencode.FilePartParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartParam{
Type: opencode.F(opencode.FilePartTypeFile),
Mime: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
2025-07-04 23:29:40 +08:00
})
}
2025-06-19 21:45:24 +08:00
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
2025-07-04 23:29:40 +08:00
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
2025-06-19 21:45:24 +08:00
return m, tea.Batch(cmds...)
2025-06-06 04:44:20 +08:00
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
return m, nil
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
2025-07-08 21:08:53 +08:00
imageBytes := clipboard.Read(clipboard.FmtImage)
if imageBytes != nil {
2025-07-09 02:02:13 +08:00
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
2025-07-08 21:08:53 +08:00
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
attachment := &textarea.Attachment{
ID: uuid.NewString(),
MediaType: "image/png",
2025-07-09 02:02:13 +08:00
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
2025-07-08 21:08:53 +08:00
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
2025-07-08 21:08:53 +08:00
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) {
m.textarea.Newline()
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
m.exitKeyInDebounce = inDebounce
}
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0]
}
func (m *editorComponent) resetTextareaStyles() textarea.Model {
2025-04-28 21:46:09 +08:00
t := theme.CurrentTheme()
2025-06-13 05:00:20 +08:00
bgColor := t.BackgroundElement()
2025-04-28 21:46:09 +08:00
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := m.textarea
2025-06-13 05:00:20 +08:00
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
2025-07-04 23:29:40 +08:00
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()
2025-07-04 23:29:40 +08:00
ta.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
2025-07-04 23:29:40 +08:00
ta.Styles.Attachment = styles.NewStyle().
Foreground(t.Secondary()).
Background(bgColor).
Lipgloss()
ta.Styles.SelectedAttachment = styles.NewStyle().
Foreground(t.Text()).
Background(t.Secondary()).
Lipgloss()
2025-06-13 05:00:20 +08:00
ta.Styles.Cursor.Color = t.Primary()
2025-04-28 21:46:09 +08:00
return ta
}
2025-06-17 04:58:46 +08:00
func createSpinner() spinner.Model {
t := theme.CurrentTheme()
2025-06-17 04:58:46 +08:00
return spinner.New(
2025-06-16 22:09:34 +08:00
spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle(
styles.NewStyle().
2025-06-27 01:21:15 +08:00
Background(t.Background()).
Foreground(t.TextMuted()).
Width(3).
Lipgloss(),
),
2025-06-16 22:09:34 +08:00
)
2025-06-17 04:58:46 +08:00
}
func NewEditorComponent(app *app.App) EditorComponent {
2025-06-17 04:58:46 +08:00
s := createSpinner()
2025-06-06 04:44:20 +08:00
ta := textarea.New()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
m := &editorComponent{
app: app,
textarea: ta,
spinner: s,
interruptKeyInDebounce: false,
2025-04-12 00:54:13 +08:00
}
m.resetTextareaStyles()
return m
2025-04-12 00:54:13 +08:00
}