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 +- 2 files changed, 148 insertions(+), 24 deletions(-) (limited to 'packages/tui/internal/components/chat') 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 { -- cgit v1.2.3