summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/chat
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-21 05:52:02 -0500
committeradamdotdevin <[email protected]>2025-07-21 05:52:11 -0500
commit8e8796507d9adcb89341dfe01ec499938611ebea (patch)
treea4d503f71eed1280a478b1adaf2037a88594eac3 /packages/tui/internal/components/chat
parentcef5c295834760d9d3a57334f2e52bd528c66e68 (diff)
downloadopencode-8e8796507d9adcb89341dfe01ec499938611ebea.tar.gz
opencode-8e8796507d9adcb89341dfe01ec499938611ebea.zip
feat(tui): message history select with up/down arrows
Diffstat (limited to 'packages/tui/internal/components/chat')
-rw-r--r--packages/tui/internal/components/chat/editor.go164
-rw-r--r--packages/tui/internal/components/chat/messages.go8
2 files changed, 148 insertions, 24 deletions
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 {