summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tui/internal/components')
-rw-r--r--packages/tui/internal/components/chat/editor.go164
-rw-r--r--packages/tui/internal/components/chat/messages.go8
-rw-r--r--packages/tui/internal/components/dialog/models.go5
-rw-r--r--packages/tui/internal/components/textarea/textarea.go82
4 files changed, 200 insertions, 59 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 {
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)
}