diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 043d9fcd..74047af1 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -37,6 +37,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-yaml v1.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 29548273..fdc5bbb0 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 6b59acae..469857ab 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -44,7 +44,7 @@ type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { Text string - Attachments []Attachment + Attachments []opencode.FilePartParam } type OptimisticMessageAddedMsg struct { Message opencode.Message @@ -217,13 +217,6 @@ func getDefaultModel( return nil } -type Attachment struct { - FilePath string - FileName string - MimeType string - Content []byte -} - func (a *App) IsBusy() bool { if len(a.Messages) == 0 { return false @@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { return session, nil } -func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd { +func (a *App) SendChatMessage( + ctx context.Context, + text string, + attachments []opencode.FilePartParam, +) (*App, tea.Cmd) { var cmds []tea.Cmd if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { - return toast.NewErrorToast(err.Error()) + return a, toast.NewErrorToast(err.Error()) } a.Session = session cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) } + optimisticParts := []opencode.MessagePart{{ + Type: opencode.MessagePartTypeText, + Text: text, + }} + if len(attachments) > 0 { + for _, attachment := range attachments { + optimisticParts = append(optimisticParts, opencode.MessagePart{ + Type: opencode.MessagePartTypeFile, + Filename: attachment.Filename.Value, + MediaType: attachment.MediaType.Value, + URL: attachment.URL.Value, + }) + } + } + optimisticMessage := opencode.Message{ - ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), - Role: opencode.MessageRoleUser, - Parts: []opencode.MessagePart{{ - Type: opencode.MessagePartTypeText, - Text: text, - }}, + ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), + Role: opencode.MessageRoleUser, + Parts: optimisticParts, Metadata: opencode.MessageMetadata{ SessionID: a.Session.ID, Time: opencode.MessageMetadataTime{ @@ -326,13 +335,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) cmds = append(cmds, func() tea.Msg { + parts := []opencode.MessagePartUnionParam{ + opencode.TextPartParam{ + Type: opencode.F(opencode.TextPartTypeText), + Text: opencode.F(text), + }, + } + if len(attachments) > 0 { + for _, attachment := range attachments { + parts = append(parts, opencode.FilePartParam{ + MediaType: attachment.MediaType, + Type: attachment.Type, + URL: attachment.URL, + Filename: attachment.Filename, + }) + } + } + _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ - Parts: opencode.F([]opencode.MessagePartUnionParam{ - opencode.TextPartParam{ - Type: opencode.F(opencode.TextPartTypeText), - Text: opencode.F(text), - }, - }), + Parts: opencode.F(parts), ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), }) @@ -346,7 +367,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At // The actual response will come through SSE // For now, just return success - return tea.Batch(cmds...) + return a, tea.Batch(cmds...) } func (a *App) Cancel(ctx context.Context, sessionID string) error { diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 669ef47d..595fd4d5 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 = " " diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index caf754c7..7ba91dc5 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -64,7 +64,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string - IsCommand bool + ProviderID string } type CompletionDialogCompleteItemMsg struct { @@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var query string query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } if query != c.query { c.query = query @@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string { for _, cmd := range completions { title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 + width := lipgloss.Width(title) + if width > maxWidth-4 { + maxWidth = width + 4 } } @@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - // Check if this is a command completion - isCommand := c.completionProvider.GetId() == "commands" - return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ SearchString: value, CompletionValue: item.GetValue(), - IsCommand: isCommand, + ProviderID: c.completionProvider.GetId(), }), c.close(), ) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go index 489b9f29..3fc6e599 100644 --- a/packages/tui/internal/components/dialog/find.go +++ b/packages/tui/internal/components/dialog/find.go @@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string { f.list.SetMaxWidth(f.width - 4) inputView := f.textInput.View() inputView = styles.NewStyle(). - Background(t.BackgroundPanel()). + Background(t.BackgroundElement()). Height(1). Width(f.width-4). Padding(0, 0). @@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd { func createTextInput(existing *textinput.Model) textinput.Model { t := theme.CurrentTheme() - bgColor := t.BackgroundPanel() + bgColor := t.BackgroundElement() textColor := t.Text() textMutedColor := t.TextMuted() diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 4ebf572e..f8cda82a 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string { displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName) return styles.NewStyle(). Background(t.Primary()). - Foreground(t.BackgroundElement()). + Foreground(t.BackgroundPanel()). Width(width). PaddingLeft(1). Render(displayText) } else { modelStyle := styles.NewStyle(). Foreground(t.Text()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) providerStyle := styles.NewStyle(). Foreground(t.TextMuted()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) modelPart := modelStyle.Render(m.ModelName) providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName)) combinedText := modelPart + providerPart return styles.NewStyle(). - Background(t.BackgroundElement()). + Background(t.BackgroundPanel()). PaddingLeft(1). Render(combinedText) } diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go index a7ea3458..16bc73ca 100644 --- a/packages/tui/internal/components/list/list.go +++ b/packages/tui/internal/components/list/list.go @@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string { return strings.Join(listItems, "\n") } -func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { +func NewListComponent[T ListItem]( + items []T, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[T] { return &listComponent[T]{ fallbackMsg: fallbackMsg, items: items, @@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string { } // NewStringList creates a new list component with string items -func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] { +func NewStringList( + items []string, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[StringItem] { stringItems := make([]StringItem, len(items)) for i, item := range items { stringItems[i] = StringItem(item) diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index aa81a83e..5c2fbf8b 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string { innerWidth := outerWidth - 4 - baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()) + baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()) var finalContent string if m.title != "" { @@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string { modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.BorderActive()), + layout.WithOverlayBorderColor(t.Primary()), ) } diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go index ccf28200..233bcf52 100644 --- a/packages/tui/internal/components/qr/qr.go +++ b/packages/tui/internal/components/qr/qr.go @@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) { } // Create lipgloss style for QR code with theme colors - qrStyle := styles.NewStyleWithColors(t.Text(), t.Background()) + qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background()) var result strings.Builder diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 2ca08bb8..c2c92ea7 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -9,6 +9,8 @@ import ( "time" "unicode" + "slices" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" @@ -17,7 +19,6 @@ import ( "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" - "slices" ) const ( @@ -32,6 +33,145 @@ const ( maxLines = 10000 ) +// Attachment represents a special object within the text, distinct from regular characters. +type Attachment struct { + ID string // A unique identifier for this attachment instance + Display string // e.g., "@filename.txt" + URL string + Filename string + MediaType string +} + +// Helper functions for converting between runes and any slices + +// runesToInterfaces converts a slice of runes to a slice of interfaces +func runesToInterfaces(runes []rune) []any { + result := make([]any, len(runes)) + for i, r := range runes { + result[i] = r + } + return result +} + +// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes) +func interfacesToRunes(items []any) []rune { + var result []rune + for _, item := range items { + switch val := item.(type) { + case rune: + result = append(result, val) + case *Attachment: + result = append(result, []rune(val.Display)...) + } + } + return result +} + +// copyInterfaceSlice creates a copy of an any slice +func copyInterfaceSlice(src []any) []any { + dst := make([]any, len(src)) + copy(dst, src) + return dst +} + +// interfacesToString converts a slice of interfaces to a string for display +func interfacesToString(items []any) string { + var s strings.Builder + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteRune(val) + case *Attachment: + s.WriteString(val.Display) + } + } + return s.String() +} + +// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment. +// This allows for proper highlighting even when the cursor is technically at the position +// after the attachment object in the underlying slice. +func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { + if m.row >= len(m.value) { + return nil, -1, -1 + } + + row := m.value[m.row] + col := m.col + + if col < 0 || col > len(row) { + return nil, -1, -1 + } + + // Check if the cursor is at the same index as an attachment. + if col < len(row) { + if att, ok := row[col].(*Attachment); ok { + return att, col, col + } + } + + // Check if the cursor is immediately after an attachment. This is a common + // state, for example, after just inserting one. + if col > 0 && col <= len(row) { + if att, ok := row[col-1].(*Attachment); ok { + return att, col - 1, col - 1 + } + } + + return nil, -1, -1 +} + +// renderLineWithAttachments renders a line with proper attachment highlighting +func (m Model) renderLineWithAttachments( + items []any, + style lipgloss.Style, +) string { + var s strings.Builder + currentAttachment, _, _ := m.isAttachmentAtCursor() + + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteString(style.Render(string(val))) + case *Attachment: + // Check if this is the attachment the cursor is currently on + if currentAttachment != nil && currentAttachment.ID == val.ID { + // Cursor is on this attachment, highlight it + s.WriteString(m.Styles.SelectedAttachment.Render(val.Display)) + } else { + s.WriteString(m.Styles.Attachment.Render(val.Display)) + } + } + } + return s.String() +} + +// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune +func getRuneAt(items []any, index int) rune { + if index < 0 || index >= len(items) { + return 0 + } + if r, ok := items[index].(rune); ok { + return r + } + return 0 +} + +// isSpaceAt checks if the item at index is a space rune +func isSpaceAt(items []any, index int) bool { + r := getRuneAt(items, index) + return r != 0 && unicode.IsSpace(r) +} + +// setRuneAt safely sets a rune at a specific position if it's a rune +func setRuneAt(items []any, index int, r rune) { + if index >= 0 && index < len(items) { + if _, ok := items[index].(rune); ok { + items[index] = r + } + } +} + // Internal messages for clipboard operations. type ( pasteMsg string @@ -70,30 +210,96 @@ type KeyMap struct { // upon the textarea. func DefaultKeyMap() KeyMap { return KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), - Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + CharacterForward: key.NewBinding( + key.WithKeys("right", "ctrl+f"), + key.WithHelp("right", "character forward"), + ), + CharacterBackward: key.NewBinding( + key.WithKeys("left", "ctrl+b"), + key.WithHelp("left", "character backward"), + ), + WordForward: key.NewBinding( + key.WithKeys("alt+right", "alt+f"), + key.WithHelp("alt+right", "word forward"), + ), + WordBackward: key.NewBinding( + key.WithKeys("alt+left", "alt+b"), + key.WithHelp("alt+left", "word backward"), + ), + LineNext: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("down", "next line"), + ), + LinePrevious: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("up", "previous line"), + ), + DeleteWordBackward: key.NewBinding( + key.WithKeys("alt+backspace", "ctrl+w"), + key.WithHelp("alt+backspace", "delete word backward"), + ), + DeleteWordForward: key.NewBinding( + key.WithKeys("alt+delete", "alt+d"), + key.WithHelp("alt+delete", "delete word forward"), + ), + DeleteAfterCursor: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+k", "delete after cursor"), + ), + DeleteBeforeCursor: key.NewBinding( + key.WithKeys("ctrl+u"), + key.WithHelp("ctrl+u", "delete before cursor"), + ), + InsertNewline: key.NewBinding( + key.WithKeys("enter", "ctrl+m"), + key.WithHelp("enter", "insert newline"), + ), + DeleteCharacterBackward: key.NewBinding( + key.WithKeys("backspace", "ctrl+h"), + key.WithHelp("backspace", "delete character backward"), + ), + DeleteCharacterForward: key.NewBinding( + key.WithKeys("delete", "ctrl+d"), + key.WithHelp("delete", "delete character forward"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end", "line end"), + ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + InputBegin: key.NewBinding( + key.WithKeys("alt+<", "ctrl+home"), + key.WithHelp("alt+<", "input begin"), + ), + InputEnd: key.NewBinding( + key.WithKeys("alt+>", "ctrl+end"), + key.WithHelp("alt+>", "input end"), + ), - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), + CapitalizeWordForward: key.NewBinding( + key.WithKeys("alt+c"), + key.WithHelp("alt+c", "capitalize word forward"), + ), + LowercaseWordForward: key.NewBinding( + key.WithKeys("alt+l"), + key.WithHelp("alt+l", "lowercase word forward"), + ), + UppercaseWordForward: key.NewBinding( + key.WithKeys("alt+u"), + key.WithHelp("alt+u", "uppercase word forward"), + ), - TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + TransposeCharacterBackward: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "transpose character backward"), + ), } } @@ -160,9 +366,11 @@ type CursorStyle struct { // states. The appropriate styles will be chosen based on the focus state of // the textarea. type Styles struct { - Focused StyleState - Blurred StyleState - Cursor CursorStyle + Focused StyleState + Blurred StyleState + Cursor CursorStyle + Attachment lipgloss.Style + SelectedAttachment lipgloss.Style } // StyleState that will be applied to the text area. @@ -217,13 +425,22 @@ func (s StyleState) computedText() lipgloss.Style { // line is the input to the text wrapping function. This is stored in a struct // so that it can be hashed and memoized. type line struct { - runes []rune - width int + content []any // Contains runes and *Attachment + width int } // Hash returns a hash of the line. func (w line) Hash() string { - v := fmt.Sprintf("%s:%d", string(w.runes), w.width) + var s strings.Builder + for _, item := range w.content { + switch v := item.(type) { + case rune: + s.WriteRune(v) + case *Attachment: + s.WriteString(v.ID) + } + } + v := fmt.Sprintf("%s:%d", s.String(), w.width) return fmt.Sprintf("%x", sha256.Sum256([]byte(v))) } @@ -232,7 +449,7 @@ type Model struct { Err error // General settings. - cache *MemoCache[line, [][]rune] + cache *MemoCache[line, [][]any] // Prompt is printed at the beginning of each line. // @@ -295,14 +512,14 @@ type Model struct { // if there are more lines than the permitted height. height int - // Underlying text value. - value [][]rune + // Underlying text value. Contains either rune or *Attachment types. + value [][]any // focus indicates whether user input focus should be on this input // component. When false, ignore keyboard input and hide the cursor. focus bool - // Cursor column. + // Cursor column (slice index). col int // Cursor row. @@ -328,14 +545,14 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - cache: NewMemoCache[line, [][]rune](maxLines), + cache: NewMemoCache[line, [][]any](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, VirtualCursor: true, virtualCursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), + value: make([][]any, minHeight, maxLines), focus: false, col: 0, row: 0, @@ -354,25 +571,40 @@ func DefaultStyles(isDark bool) Styles { var s Styles s.Focused = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(), } s.Blurred = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } + s.Attachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) + s.SelectedAttachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) s.Cursor = CursorStyle{ Color: lipgloss.Color("7"), Shape: tea.CursorBlock, @@ -429,6 +661,75 @@ func (m *Model) InsertRune(r rune) { m.insertRunesFromUserInput([]rune{r}) } +// InsertAttachment inserts an attachment at the cursor position. +func (m *Model) InsertAttachment(att *Attachment) { + if m.CharLimit > 0 { + availSpace := m.CharLimit - m.Length() + // If the char limit's been reached, cancel. + if availSpace <= 0 { + return + } + } + + // Insert the attachment at the current cursor position + m.value[m.row] = append( + m.value[m.row][:m.col], + append([]any{att}, m.value[m.row][m.col:]...)...) + m.col++ + m.SetCursorColumn(m.col) +} + +// ReplaceRange replaces text from startCol to endCol on the current row with the given string. +// This preserves attachments outside the replaced range. +func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { + if m.row >= len(m.value) || startCol < 0 || endCol < startCol { + return + } + + // Ensure bounds are within the current row + rowLen := len(m.value[m.row]) + startCol = max(0, min(startCol, rowLen)) + endCol = max(startCol, min(endCol, rowLen)) + + // Create new row content: before + replacement + after + before := m.value[m.row][:startCol] + after := m.value[m.row][endCol:] + replacementRunes := runesToInterfaces([]rune(replacement)) + + // Combine the parts + newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after)) + newRow = append(newRow, before...) + newRow = append(newRow, replacementRunes...) + newRow = append(newRow, after...) + + m.value[m.row] = newRow + + // Position cursor at end of replacement + m.col = startCol + len(replacementRunes) + m.SetCursorColumn(m.col) +} + +// CurrentRowLength returns the length of the current row. +func (m *Model) CurrentRowLength() int { + if m.row >= len(m.value) { + return 0 + } + return len(m.value[m.row]) +} + +// GetAttachments returns all attachments in the textarea. +func (m Model) GetAttachments() []*Attachment { + var attachments []*Attachment + for _, row := range m.value { + for _, item := range row { + if att, ok := item.(*Attachment); ok { + attachments = append(attachments, att) + } + } + } + return attachments +} + // 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 @@ -481,23 +782,22 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Save the remainder of the original line at the current // cursor position. - tail := make([]rune, len(m.value[m.row][m.col:])) - copy(tail, m.value[m.row][m.col:]) + tail := copyInterfaceSlice(m.value[m.row][m.col:]) // Paste the first line at the current cursor position. - m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...) + m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...) m.col += len(lines[0]) if numExtraLines := len(lines) - 1; numExtraLines > 0 { // Add the new lines. // We try to reuse the slice if there's already space. - var newGrid [][]rune + var newGrid [][]any if cap(m.value) >= len(m.value)+numExtraLines { // Can reuse the extra space. newGrid = m.value[:len(m.value)+numExtraLines] } else { // No space left; need a new slice. - newGrid = make([][]rune, len(m.value)+numExtraLines) + newGrid = make([][]any, len(m.value)+numExtraLines) copy(newGrid, m.value[:m.row+1]) } // Add all the rows that were after the cursor in the original @@ -507,7 +807,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Insert all the new lines in the middle. for _, l := range lines[1:] { m.row++ - m.value[m.row] = l + m.value[m.row] = runesToInterfaces(l) m.col = len(l) } } @@ -526,7 +826,14 @@ func (m Model) Value() string { var v strings.Builder for _, l := range m.value { - v.WriteString(string(l)) + for _, item := range l { + switch val := item.(type) { + case rune: + v.WriteRune(val) + case *Attachment: + v.WriteString(val.Display) + } + } v.WriteByte('\n') } @@ -537,7 +844,14 @@ func (m Model) Value() string { func (m *Model) Length() int { var l int for _, row := range m.value { - l += uniseg.StringWidth(string(row)) + for _, item := range row { + switch val := item.(type) { + case rune: + l += rw.RuneWidth(val) + case *Attachment: + l += uniseg.StringWidth(val.Display) + } + } } // We add len(m.value) to include the newline characters. return l + len(m.value) - 1 @@ -553,6 +867,29 @@ func (m Model) Line() int { return m.row } +// CursorColumn returns the cursor's column position (slice index). +func (m Model) CursorColumn() int { + return m.col +} + +// LastRuneIndex returns the index of the last occurrence of a rune on the current line, +// searching backwards from the current cursor position. +// Returns -1 if the rune is not found before the cursor. +func (m Model) LastRuneIndex(r rune) int { + if m.row >= len(m.value) { + return -1 + } + // Iterate backwards from just before the cursor position + for i := m.col - 1; i >= 0; i-- { + if i < len(m.value[m.row]) { + if item, ok := m.value[m.row][i].(rune); ok && item == r { + return i + } + } + } + return -1 +} + func (m *Model) Newline() { if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { return @@ -561,6 +898,39 @@ func (m *Model) Newline() { m.splitLine(m.row, m.col) } +// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index. +// This is used to maintain the cursor's horizontal position when moving vertically. +func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { + if row < 0 || row >= len(m.value) { + return 0 + } + + offset := 0 + // Find the slice index that corresponds to the visual offset. + for i, item := range m.value[row] { + var itemWidth int + switch v := item.(type) { + case rune: + itemWidth = rw.RuneWidth(v) + case *Attachment: + itemWidth = uniseg.StringWidth(v.Display) + } + + // If the target offset falls within the current item, this is our index. + if offset+itemWidth > charOffset { + // Decide whether to stick with the previous index or move to the current + // one based on which is closer to the target offset. + if (charOffset - offset) > ((offset + itemWidth) - charOffset) { + return i + 1 + } + return i + } + offset += itemWidth + } + + return len(m.value[row]) +} + // CursorDown moves the cursor down by one line. // Returns whether or not the cursor blink should be reset. func (m *Model) CursorDown() { @@ -569,31 +939,15 @@ func (m *Model) CursorDown() { m.lastCharOffset = charOffset if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 { + // Move to the next model line m.row++ - m.col = 0 - } else { - // Move the cursor to the start of the next line so that we can get - // the line information. We need to add 2 columns to account for the - // trailing space wrapping. - const trailingSpace = 2 - m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1) - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break - } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset+1 < li.Height { + // Move to the next wrapped line within the same model line + startOfNextWrappedLine := li.StartColumn + li.Width + m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // CursorUp moves the cursor up by one line. @@ -603,32 +957,24 @@ func (m *Model) CursorUp() { m.lastCharOffset = charOffset if li.RowOffset <= 0 && m.row > 0 { + // Move to the previous model line m.row-- - m.col = len(m.value[m.row]) - } else { - // Move the cursor to the end of the previous line. - // This can be done by moving the cursor to the start of the line and - // then subtracting 2 to account for the trailing space we keep on - // soft-wrapped lines. - const trailingSpace = 2 - m.col = li.StartColumn - trailingSpace - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset > 0 { + // Move to the previous wrapped line within the same model line + // To do this, we need to find the start of the previous wrapped line. + prevLineInfo := m.LineInfo() + // prevLineStart := 0 + if prevLineInfo.RowOffset > 0 { + // This is complex, so we'll approximate by moving to the start of the current wrapped line + // and then letting characterLeft handle it. A more precise calculation would + // require re-wrapping to find the previous line's start. + // For now, a simpler approach: + m.col = li.StartColumn - 1 } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // SetCursorColumn moves the cursor to the given position. If the position is @@ -680,7 +1026,7 @@ func (m *Model) Blur() { // Reset sets the input to its default state with no input. func (m *Model) Reset() { - m.value = make([][]rune, minHeight, maxLines) + m.value = make([][]any, minHeight, maxLines) m.col = 0 m.row = 0 m.SetCursorColumn(0) @@ -741,7 +1087,7 @@ func (m *Model) deleteWordLeft() { oldCol := m.col //nolint:ifshort m.SetCursorColumn(m.col - 1) - for unicode.IsSpace(m.value[m.row][m.col]) { + for isSpaceAt(m.value[m.row], m.col) { if m.col <= 0 { break } @@ -750,7 +1096,7 @@ func (m *Model) deleteWordLeft() { } for m.col > 0 { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col - 1) } else { if m.col > 0 { @@ -776,13 +1122,13 @@ func (m *Model) deleteWordRight() { oldCol := m.col - for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) { + for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) { // ignore series of whitespace after cursor m.SetCursorColumn(m.col + 1) } for m.col < len(m.value[m.row]) { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col + 1) } else { break @@ -832,13 +1178,13 @@ func (m *Model) characterLeft(insideLine bool) { func (m *Model) wordLeft() { for { m.characterLeft(true /* insideLine */) - if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { + if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) { break } } for m.col > 0 { - if unicode.IsSpace(m.value[m.row][m.col-1]) { + if isSpaceAt(m.value[m.row], m.col-1) { break } m.SetCursorColumn(m.col - 1) @@ -854,7 +1200,7 @@ func (m *Model) wordRight() { func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // Skip spaces forward. - for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) { + for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) { if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { // End of text. break @@ -864,7 +1210,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { charIdx := 0 for m.col < len(m.value[m.row]) { - if unicode.IsSpace(m.value[m.row][m.col]) { + if isSpaceAt(m.value[m.row], m.col) { break } fn(charIdx, m.col) @@ -876,14 +1222,18 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // uppercaseRight changes the word to the right to uppercase. func (m *Model) uppercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToUpper(r) + } }) } // lowercaseRight changes the word to the right to lowercase. func (m *Model) lowercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToLower(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToLower(r) + } }) } @@ -891,7 +1241,9 @@ func (m *Model) lowercaseRight() { func (m *Model) capitalizeRight() { m.doWordRight(func(charIdx int, i int) { if charIdx == 0 { - m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToTitle(r) + } } }) } @@ -905,34 +1257,39 @@ func (m Model) LineInfo() LineInfo { // m.col and counting the number of runes that we need to skip. var counter int for i, line := range grid { - // We've found the line that we are on - if counter+len(line) == m.col && i+1 < len(grid) { - // We wrap around to the next line if we are at the end of the - // previous line so that we can be at the very beginning of the row - return LineInfo{ - CharOffset: 0, - ColumnOffset: 0, - Height: len(grid), - RowOffset: i + 1, - StartColumn: m.col, - Width: len(grid[i+1]), - CharWidth: uniseg.StringWidth(string(line)), - } - } + start := counter + end := counter + len(line) + + if m.col >= start && m.col <= end { + // This is the wrapped line the cursor is on. + + // Special case: if the cursor is at the end of a wrapped line, + // and there's another wrapped line after it, the cursor should + // be considered at the beginning of the next line. + if m.col == end && i < len(grid)-1 { + nextLine := grid[i+1] + return LineInfo{ + CharOffset: 0, + ColumnOffset: 0, + Height: len(grid), + RowOffset: i + 1, + StartColumn: end, + Width: len(nextLine), + CharWidth: uniseg.StringWidth(interfacesToString(nextLine)), + } + } - if counter+len(line) >= m.col { return LineInfo{ - CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])), - ColumnOffset: m.col - counter, + CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])), + ColumnOffset: m.col - start, Height: len(grid), RowOffset: i, - StartColumn: counter, + StartColumn: start, Width: len(line), - CharWidth: uniseg.StringWidth(string(line)), + CharWidth: uniseg.StringWidth(interfacesToString(line)), } } - - counter += len(line) + counter = end } return LineInfo{} } @@ -1060,12 +1417,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd + if m.row >= len(m.value) { + m.value = append(m.value, make([]any, 0)) + } if m.value[m.row] == nil { - m.value[m.row] = make([]rune, 0) + m.value[m.row] = make([]any, 0) } if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() { - m.cache = NewMemoCache[line, [][]rune](m.MaxHeight) + m.cache = NewMemoCache[line, [][]any](m.MaxHeight) } switch msg := msg.(type) { @@ -1093,11 +1453,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.mergeLineAbove(m.row) break } - if len(m.value[m.row]) > 0 { - m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...) - if m.col > 0 { - m.SetCursorColumn(m.col - 1) - } + if len(m.value[m.row]) > 0 && m.col > 0 { + m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) + m.SetCursorColumn(m.col - 1) } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { @@ -1154,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.insertRunesFromUserInput([]rune{msg.Code}) } case pasteMsg: @@ -1226,7 +1584,8 @@ func (m Model) View() string { widestLineNumber = lnw } - strwidth := uniseg.StringWidth(string(wrappedLine)) + wrappedLineStr := interfacesToString(wrappedLine) + strwidth := uniseg.StringWidth(wrappedLineStr) padding := m.width - strwidth // If the trailing space causes the line to be wider than the // width, we should not draw it to the screen since it will result @@ -1236,22 +1595,46 @@ func (m Model) View() string { // The character causing the line to be wider than the width is // guaranteed to be a space since any other character would // have been wrapped. - wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) + wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ") padding -= m.width - strwidth } + if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) + // Render the part of the line before the cursor + s.WriteString( + m.renderLineWithAttachments( + wrappedLine[:lineInfo.ColumnOffset], + style, + ), + ) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.virtualCursor.SetChar(" ") s.WriteString(m.virtualCursor.View()) + } else if lineInfo.ColumnOffset < len(wrappedLine) { + // Render the item under the cursor + item := wrappedLine[lineInfo.ColumnOffset] + if att, ok := item.(*Attachment); ok { + // Item at cursor is an attachment. Render it with the selection style. + // This becomes the "cursor" visually. + s.WriteString(m.Styles.SelectedAttachment.Render(att.Display)) + } else { + // Item at cursor is a rune. Render it with the virtual cursor. + m.virtualCursor.SetChar(string(item.(rune))) + s.WriteString(style.Render(m.virtualCursor.View())) + } + + // Render the part of the line after the cursor + s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style)) } else { - m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + // Cursor is at the end of the line + m.virtualCursor.SetChar(" ") s.WriteString(style.Render(m.virtualCursor.View())) - s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { - s.WriteString(style.Render(string(wrappedLine))) + s.WriteString(m.renderLineWithAttachments(wrappedLine, style)) } + s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) s.WriteRune('\n') newLines++ @@ -1443,12 +1826,12 @@ func (m Model) Cursor() *tea.Cursor { return c } -func (m Model) memoizedWrap(runes []rune, width int) [][]rune { - input := line{runes: runes, width: width} +func (m Model) memoizedWrap(content []any, width int) [][]any { + input := line{content: content, width: width} if v, ok := m.cache.Get(input); ok { return v } - v := wrap(runes, width) + v := wrapInterfaces(content, width) m.cache.Set(input, v) return v } @@ -1514,8 +1897,7 @@ func (m *Model) splitLine(row, col int) { // the cursor, take the content after the cursor and make it the content of // the line underneath, and shift the remaining lines down by one head, tailSrc := m.value[row][:col], m.value[row][col:] - tail := make([]rune, len(tailSrc)) - copy(tail, tailSrc) + tail := copyInterfaceSlice(tailSrc) m.value = append(m.value[:row+1], m.value[row:]...) @@ -1535,66 +1917,84 @@ func Paste() tea.Msg { return pasteMsg(str) } -func wrap(runes []rune, width int) [][]rune { +func wrapInterfaces(content []any, width int) [][]any { + if width <= 0 { + return [][]any{content} + } + var ( - lines = [][]rune{{}} - word = []rune{} - row int - spaces int + lines = [][]any{{}} + word = []any{} + wordW int + lineW int + spaceW int + inSpaces bool ) - // Word wrap the runes - for _, r := range runes { - if unicode.IsSpace(r) { - spaces++ - } else { - word = append(word, r) + for _, item := range content { + itemW := 0 + isSpace := false + + if r, ok := item.(rune); ok { + if unicode.IsSpace(r) { + isSpace = true + } + itemW = rw.RuneWidth(r) + } else if att, ok := item.(*Attachment); ok { + itemW = uniseg.StringWidth(att.Display) } - if spaces > 0 { //nolint:nestif - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width { - row++ - lines = append(lines, []rune{}) - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } else { - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } - } else { - // If the last character is a double-width rune, then we may not be able to add it to this line - // as it might cause us to go past the width. - lastCharLen := rw.RuneWidth(word[len(word)-1]) - if uniseg.StringWidth(string(word))+lastCharLen > width { - // If the current line has any content, let's move to the next - // line because the current word fills up the entire line. - if len(lines[row]) > 0 { - row++ - lines = append(lines, []rune{}) + if isSpace { + if !inSpaces { + // End of a word + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + lineW = wordW + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + lineW += wordW } - lines[row] = append(lines[row], word...) word = nil + wordW = 0 } + inSpaces = true + spaceW += itemW + } else { + if inSpaces { + // End of spaces + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + lineW = 0 + } else { + lineW += spaceW + } + // Add spaces to current line + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } + spaceW = 0 + } + inSpaces = false + word = append(word, item) + wordW += itemW } } - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width { - lines = append(lines, []rune{}) - lines[row+1] = append(lines[row+1], word...) - // We add an extra space at the end of the line to account for the - // trailing space at the end of the previous soft-wrapped lines so that - // behaviour when navigating is consistent and so that we don't need to - // continually add edges to handle the last line of the wrapped input. - spaces++ - lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...) - } else { - lines[row] = append(lines[row], word...) - spaces++ - lines[row] = append(lines[row], repeatSpaces(spaces)...) + // Handle any remaining word/spaces + if wordW > 0 { + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + } + } + if spaceW > 0 { + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + } + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } } return lines diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go deleted file mode 100644 index a03346eb..00000000 --- a/packages/tui/internal/layout/flex_example_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package layout_test - -import ( - "fmt" - "github.com/sst/opencode/internal/layout" -) - -func ExampleRender_withGap() { - // Create a horizontal layout with 3px gap between items - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 3, - }, - layout.FlexItem{View: "Item1"}, - layout.FlexItem{View: "Item2"}, - layout.FlexItem{View: "Item3"}, - ) - fmt.Println(result) - // Output: Item1 Item2 Item3 -} - -func ExampleRender_withGapAndJustify() { - // Create a horizontal layout with gap and space-between justification - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 2, - Justify: layout.JustifySpaceBetween, - }, - layout.FlexItem{View: "A"}, - layout.FlexItem{View: "B"}, - layout.FlexItem{View: "C"}, - ) - fmt.Println(result) - // Output: A B C -} diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go deleted file mode 100644 index cad38dc8..00000000 --- a/packages/tui/internal/layout/flex_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package layout - -import ( - "strings" - "testing" -) - -func TestFlexGap(t *testing.T) { - tests := []struct { - name string - opts FlexOptions - items []FlexItem - expected string - }{ - { - name: "Row with gap", - opts: FlexOptions{ - Direction: Row, - Width: 20, - Height: 1, - Gap: 2, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "Column with gap", - opts: FlexOptions{ - Direction: Column, - Width: 1, - Height: 5, - Gap: 1, - Align: AlignStart, - }, - items: []FlexItem{ - {View: "A", FixedSize: 1}, - {View: "B", FixedSize: 1}, - {View: "C", FixedSize: 1}, - }, - expected: "A\n \nB\n \nC", - }, - { - name: "Row with gap and justify space between", - opts: FlexOptions{ - Direction: Row, - Width: 15, - Height: 1, - Gap: 1, - Justify: JustifySpaceBetween, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "No gap specified", - opts: FlexOptions{ - Direction: Row, - Width: 10, - Height: 1, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "ABC", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Render(tt.opts, tt.items...) - // Trim any trailing spaces for comparison - result = strings.TrimRight(result, " ") - expected := strings.TrimRight(tt.expected, " ") - - if result != expected { - t.Errorf("Render() = %q, want %q", result, expected) - } - }) - } -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 29235229..150a1b26 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -52,7 +52,9 @@ type appModel struct { messages chat.MessagesComponent completions dialog.CompletionDialog commandProvider dialog.CompletionProvider + fileProvider dialog.CompletionProvider showCompletionDialog bool + fileCompletionActive bool leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager @@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { !a.showCompletionDialog && a.editor.Value() == "" { a.showCompletionDialog = true + a.fileCompletionActive = false updated, cmd := a.editor.Update(msg) a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) + // Set command provider for command completion + a.completions = dialog.NewCompletionDialogComponent(a.commandProvider) + updated, cmd = a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + + return a, tea.Sequence(cmds...) + } + + // Handle file completions trigger + if keyString == "@" && + !a.showCompletionDialog { + a.showCompletionDialog = true + a.fileCompletionActive = true + + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + + // Set file provider for file completion + a.completions = dialog.NewCompletionDialogComponent(a.fileProvider) updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCompletionDialog { switch keyString { - case "tab", "enter", "esc", "ctrl+c": + case "tab", "enter", "esc", "ctrl+c", "up", "down": updated, cmd := a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendMsg: a.showCompletionDialog = false - cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) + a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false + a.fileCompletionActive = false case opencode.EventListResponseEventInstallationUpdated: return a, toast.NewSuccessToast( "opencode updated to "+msg.Properties.Version+", restart to apply.", @@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return nil } os.Remove(tmpfile.Name()) - // attachments := m.attachments - // m.attachments = nil return app.SendMsg{ - Text: string(content), - Attachments: []app.Attachment{}, // attachments, + Text: string(content), } }) cmds = append(cmds, cmd) @@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) func NewModel(app *app.App) tea.Model { commandProvider := completions.NewCommandCompletionProvider(app) + fileProvider := completions.NewFileAndFolderContextGroup(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) @@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model { messages: messages, completions: completions, commandProvider: commandProvider, + fileProvider: fileProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, + fileCompletionActive: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, fileViewer: fileviewer.New(app),