From 8e8796507d9adcb89341dfe01ec499938611ebea Mon Sep 17 00:00:00 2001 From: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Date: Mon, 21 Jul 2025 05:52:02 -0500 Subject: feat(tui): message history select with up/down arrows --- packages/tui/internal/components/chat/editor.go | 164 ++++++++++++++++++--- packages/tui/internal/components/chat/messages.go | 8 +- packages/tui/internal/components/dialog/models.go | 5 +- .../tui/internal/components/textarea/textarea.go | 82 ++++++----- 4 files changed, 200 insertions(+), 59 deletions(-) (limited to 'packages/tui/internal/components') diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index ef129765f..294e05b4f 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/attachment" "github.com/sst/opencode/internal/clipboard" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" @@ -43,6 +44,7 @@ type EditorComponent interface { SetValueWithAttachments(value string) SetInterruptKeyInDebounce(inDebounce bool) SetExitKeyInDebounce(inDebounce bool) + RestoreFromHistory(index int) } type editorComponent struct { @@ -52,10 +54,13 @@ type editorComponent struct { spinner spinner.Model interruptKeyInDebounce bool exitKeyInDebounce bool + historyIndex int // -1 means current (not in history) + currentText string // Store current text when navigating history } func (m *editorComponent) Init() tea.Cmd { - return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus) + return tea.Batch(m.textarea.Focus(), tea.EnableReportFocus) + // return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus) } func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -70,6 +75,49 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) return m, cmd case tea.KeyPressMsg: + // Handle up/down arrows for history navigation + switch msg.String() { + case "up": + // Only navigate history if cursor is at the first line and column + if m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0 && len(m.app.State.MessageHistory) > 0 { + if m.historyIndex == -1 { + // Save current text before entering history + m.currentText = m.textarea.Value() + m.textarea.CursorStart() + } + // Move up in history (older messages) + if m.historyIndex < len(m.app.State.MessageHistory)-1 { + m.historyIndex++ + m.RestoreFromHistory(m.historyIndex) + m.textarea.CursorStart() + } + return m, nil + } + case "down": + // Only navigate history if cursor is at the last line and we're in history navigation + if m.textarea.IsCursorAtEnd() && m.historyIndex > -1 { + // Move down in history (newer messages) + m.historyIndex-- + if m.historyIndex == -1 { + // Restore current text + m.textarea.Reset() + m.textarea.SetValue(m.currentText) + m.currentText = "" + } else { + m.RestoreFromHistory(m.historyIndex) + m.textarea.CursorEnd() + } + return m, nil + } else if m.historyIndex > -1 { + m.textarea.CursorEnd() + return m, nil + } + } + // Reset history navigation on any other input + if m.historyIndex != -1 { + m.historyIndex = -1 + m.currentText = "" + } // Maximize editor responsiveness for printable characters if msg.Text != "" { m.textarea, cmd = m.textarea.Update(msg) @@ -107,7 +155,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.ThemeSelectedMsg: m.textarea = updateTextareaStyles(m.textarea) m.spinner = createSpinner() - return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) + return m, m.textarea.Focus() case dialog.CompletionSelectedMsg: switch msg.Item.ProviderID { case "commands": @@ -151,12 +199,28 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { symbol := msg.Item.RawData.(opencode.Symbol) parts := strings.Split(symbol.Name, ".") lastPart := parts[len(parts)-1] - attachment := &textarea.Attachment{ + attachment := &attachment.Attachment{ ID: uuid.NewString(), + Type: "symbol", Display: "@" + lastPart, URL: msg.Item.Value, Filename: lastPart, MediaType: "text/plain", + Source: &attachment.SymbolSource{ + Path: symbol.Location.Uri, + Name: symbol.Name, + Kind: int(symbol.Kind), + Range: attachment.SymbolRange{ + Start: attachment.Position{ + Line: int(symbol.Location.Range.Start.Line), + Char: int(symbol.Location.Range.Start.Character), + }, + End: attachment.Position{ + Line: int(symbol.Location.Range.End.Line), + Char: int(symbol.Location.Range.End.Character), + }, + }, + }, } m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") @@ -311,28 +375,24 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } var cmds []tea.Cmd - attachments := m.textarea.GetAttachments() - fileParts := make([]opencode.FilePartInputParam, 0) - for _, attachment := range attachments { - fileParts = append(fileParts, opencode.FilePartInputParam{ - Type: opencode.F(opencode.FilePartInputTypeFile), - Mime: opencode.F(attachment.MediaType), - URL: opencode.F(attachment.URL), - Filename: opencode.F(attachment.Filename), - }) - } + + prompt := app.Prompt{Text: value, Attachments: attachments} + m.app.State.AddPromptToHistory(prompt) + cmds = append(cmds, m.app.SaveState()) updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) - cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts})) + cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt))) return m, tea.Batch(cmds...) } func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { m.textarea.Reset() + m.historyIndex = -1 + m.currentText = "" return m, nil } @@ -342,12 +402,18 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { attachmentCount := len(m.textarea.GetAttachments()) attachmentIndex := attachmentCount + 1 base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes) - attachment := &textarea.Attachment{ + attachment := &attachment.Attachment{ ID: uuid.NewString(), + Type: "file", MediaType: "image/png", Display: fmt.Sprintf("[Image #%d]", attachmentIndex), Filename: fmt.Sprintf("image-%d.png", attachmentIndex), URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile), + Source: &attachment.FileSource{ + Path: fmt.Sprintf("image-%d.png", attachmentIndex), + Mime: "image/png", + Data: imageBytes, + }, } m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") @@ -485,11 +551,43 @@ func NewEditorComponent(app *app.App) EditorComponent { textarea: ta, spinner: s, interruptKeyInDebounce: false, + historyIndex: -1, } return m } +// RestoreFromHistory restores a message from history at the given index +func (m *editorComponent) RestoreFromHistory(index int) { + if index < 0 || index >= len(m.app.State.MessageHistory) { + return + } + + entry := m.app.State.MessageHistory[index] + + m.textarea.Reset() + m.textarea.SetValue(entry.Text) + + // Sort attachments by start index in reverse order (process from end to beginning) + // This prevents index shifting issues + attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments)) + copy(attachmentsCopy, entry.Attachments) + + for i := 0; i < len(attachmentsCopy)-1; i++ { + for j := i + 1; j < len(attachmentsCopy); j++ { + if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex { + attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i] + } + } + } + + for _, att := range attachmentsCopy { + m.textarea.SetCursorColumn(att.StartIndex) + m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "") + m.textarea.InsertAttachment(att) + } +} + func getMediaTypeFromExtension(ext string) string { switch strings.ToLower(ext) { case ".jpg": @@ -503,18 +601,27 @@ func getMediaTypeFromExtension(ext string) string { } } -func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.Attachment { +func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment { ext := strings.ToLower(filepath.Ext(filePath)) mediaType := getMediaTypeFromExtension(ext) + absolutePath := filePath + if !filepath.IsAbs(filePath) { + absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath) + } // For text files, create a simple file reference if mediaType == "text/plain" { - return &textarea.Attachment{ + return &attachment.Attachment{ ID: uuid.NewString(), + Type: "file", Display: "@" + filePath, URL: fmt.Sprintf("file://./%s", filePath), Filename: filePath, MediaType: mediaType, + Source: &attachment.FileSource{ + Path: absolutePath, + Mime: mediaType, + }, } } @@ -533,25 +640,38 @@ func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.At if strings.HasPrefix(mediaType, "image/") { label = "Image" } - - return &textarea.Attachment{ + return &attachment.Attachment{ ID: uuid.NewString(), + Type: "file", MediaType: mediaType, Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex), URL: url, Filename: filePath, + Source: &attachment.FileSource{ + Path: absolutePath, + Mime: mediaType, + Data: fileBytes, + }, } } -func (m *editorComponent) createAttachmentFromPath(filePath string) *textarea.Attachment { +func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment { extension := filepath.Ext(filePath) mediaType := getMediaTypeFromExtension(extension) - - return &textarea.Attachment{ + absolutePath := filePath + if !filepath.IsAbs(filePath) { + absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath) + } + return &attachment.Attachment{ ID: uuid.NewString(), + Type: "file", Display: "@" + filePath, URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)), Filename: filePath, MediaType: mediaType, + Source: &attachment.FileSource{ + Path: absolutePath, + Mime: mediaType, + }, } } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index c44de888e..718f41924 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -154,7 +154,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetWidth(m.width) m.loading = true return m, m.renderView() - case app.SendMsg: + case app.SendPrompt: m.viewport.GotoBottom() m.tail = true return m, nil @@ -585,7 +585,11 @@ func (m *messagesComponent) renderHeader() string { Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)) shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled - headerText := util.ToMarkdown("# "+m.app.Session.Title, headerWidth-len(sessionInfo), t.Background()) + headerText := util.ToMarkdown( + "# "+m.app.Session.Title, + headerWidth-len(sessionInfo), + t.Background(), + ) var items []layout.FlexItem if shareEnabled { diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 8f1069fcb..110151147 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -127,9 +127,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if item, ok := msg.Item.(modelItem); ok { if m.isModelInRecentSection(item.model, msg.Index) { m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID) - m.app.SaveState() items := m.buildDisplayList(m.searchDialog.GetQuery()) m.searchDialog.SetItems(items) + return m, m.app.SaveState() } } return m, nil @@ -425,7 +425,8 @@ func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) if index >= 1 && index <= len(recentModels) { if index-1 < len(recentModels) { recentModel := recentModels[index-1] - return recentModel.Provider.ID == model.Provider.ID && recentModel.Model.ID == model.Model.ID + return recentModel.Provider.ID == model.Provider.ID && + recentModel.Model.ID == model.Model.ID } } diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index cc073e27d..d97b8fdc7 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" + "github.com/sst/opencode/internal/attachment" ) const ( @@ -32,15 +33,6 @@ 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 @@ -59,7 +51,7 @@ func interfacesToRunes(items []any) []rune { switch val := item.(type) { case rune: result = append(result, val) - case *Attachment: + case *attachment.Attachment: result = append(result, []rune(val.Display)...) } } @@ -80,7 +72,7 @@ func interfacesToString(items []any) string { switch val := item.(type) { case rune: s.WriteRune(val) - case *Attachment: + case *attachment.Attachment: s.WriteString(val.Display) } } @@ -90,7 +82,7 @@ func interfacesToString(items []any) 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) { +func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) { if m.row >= len(m.value) { return nil, -1, -1 } @@ -104,7 +96,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { // Check if the cursor is at the same index as an attachment. if col < len(row) { - if att, ok := row[col].(*Attachment); ok { + if att, ok := row[col].(*attachment.Attachment); ok { return att, col, col } } @@ -112,7 +104,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { // 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 { + if att, ok := row[col-1].(*attachment.Attachment); ok { return att, col - 1, col - 1 } } @@ -132,7 +124,7 @@ func (m Model) renderLineWithAttachments( switch val := item.(type) { case rune: s.WriteString(style.Render(string(val))) - case *Attachment: + case *attachment.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 @@ -435,7 +427,7 @@ func (w line) Hash() string { switch v := item.(type) { case rune: s.WriteRune(v) - case *Attachment: + case *attachment.Attachment: s.WriteString(v.ID) } } @@ -661,7 +653,7 @@ func (m *Model) InsertRune(r rune) { } // InsertAttachment inserts an attachment at the cursor position. -func (m *Model) InsertAttachment(att *Attachment) { +func (m *Model) InsertAttachment(att *attachment.Attachment) { if m.CharLimit > 0 { availSpace := m.CharLimit - m.Length() // If the char limit's been reached, cancel. @@ -716,16 +708,36 @@ func (m *Model) CurrentRowLength() int { 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 { +// GetAttachments returns all attachments in the textarea with accurate position indices. +func (m Model) GetAttachments() []*attachment.Attachment { + var attachments []*attachment.Attachment + position := 0 // Track absolute position in the text + + for rowIdx, row := range m.value { + colPosition := 0 // Track position within the current row + for _, item := range row { - if att, ok := item.(*Attachment); ok { - attachments = append(attachments, att) + switch v := item.(type) { + case *attachment.Attachment: + // Clone the attachment to avoid modifying the original + att := *v + att.StartIndex = position + colPosition + att.EndIndex = position + colPosition + len(v.Display) + attachments = append(attachments, &att) + colPosition += len(v.Display) + case rune: + colPosition++ } } + + // Add newline character position (except for last row) + if rowIdx < len(m.value)-1 { + position += colPosition + 1 // +1 for newline + } else { + position += colPosition + } } + return attachments } @@ -829,7 +841,7 @@ func (m Model) Value() string { switch val := item.(type) { case rune: v.WriteRune(val) - case *Attachment: + case *attachment.Attachment: v.WriteString(val.Display) } } @@ -847,7 +859,7 @@ func (m *Model) Length() int { switch val := item.(type) { case rune: l += rw.RuneWidth(val) - case *Attachment: + case *attachment.Attachment: l += uniseg.StringWidth(val.Display) } } @@ -911,7 +923,7 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { switch v := item.(type) { case rune: itemWidth = rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: itemWidth = uniseg.StringWidth(v.Display) } @@ -952,7 +964,7 @@ func (m *Model) CursorDown() { switch v := item.(type) { case rune: itemWidth = rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: itemWidth = uniseg.StringWidth(v.Display) } if offset+itemWidth > charOffset { @@ -988,7 +1000,7 @@ func (m *Model) CursorDown() { switch v := item.(type) { case rune: itemWidth = rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: itemWidth = uniseg.StringWidth(v.Display) } if offset+itemWidth > charOffset { @@ -1034,7 +1046,7 @@ func (m *Model) CursorUp() { switch v := item.(type) { case rune: itemWidth = rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: itemWidth = uniseg.StringWidth(v.Display) } if offset+itemWidth > charOffset { @@ -1070,7 +1082,7 @@ func (m *Model) CursorUp() { switch v := item.(type) { case rune: itemWidth = rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: itemWidth = uniseg.StringWidth(v.Display) } if offset+itemWidth > charOffset { @@ -1111,6 +1123,10 @@ func (m *Model) CursorEnd() { m.SetCursorColumn(len(m.value[m.row])) } +func (m *Model) IsCursorAtEnd() bool { + return m.CursorColumn() == len(m.value[m.row]) +} + // Focused returns the focus state on the model. func (m Model) Focused() bool { return m.focus @@ -1725,7 +1741,7 @@ func (m Model) View() string { } else if lineInfo.ColumnOffset < len(wrappedLine) { // Render the item under the cursor item := wrappedLine[lineInfo.ColumnOffset] - if att, ok := item.(*Attachment); ok { + if att, ok := item.(*attachment.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)) @@ -2023,7 +2039,7 @@ func itemWidth(item any) int { switch v := item.(type) { case rune: return rw.RuneWidth(v) - case *Attachment: + case *attachment.Attachment: return uniseg.StringWidth(v.Display) } return 0 @@ -2052,7 +2068,7 @@ func wrapInterfaces(content []any, width int) [][]any { isSpace = true } itemW = rw.RuneWidth(r) - } else if att, ok := item.(*Attachment); ok { + } else if att, ok := item.(*attachment.Attachment); ok { itemW = uniseg.StringWidth(att.Display) } -- cgit v1.2.3