summaryrefslogtreecommitdiffhomepage
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
parentcef5c295834760d9d3a57334f2e52bd528c66e68 (diff)
downloadopencode-8e8796507d9adcb89341dfe01ec499938611ebea.tar.gz
opencode-8e8796507d9adcb89341dfe01ec499938611ebea.zip
feat(tui): message history select with up/down arrows
-rw-r--r--packages/tui/internal/app/app.go103
-rw-r--r--packages/tui/internal/app/prompt.go210
-rw-r--r--packages/tui/internal/app/state.go (renamed from packages/tui/internal/config/config.go)11
-rw-r--r--packages/tui/internal/attachment/attachment.go65
-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
-rw-r--r--packages/tui/internal/tui/tui.go17
9 files changed, 515 insertions, 150 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index b73cfc3cd..a481cd101 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -6,7 +6,6 @@ import (
"path/filepath"
"sort"
"strings"
- "time"
"log/slog"
@@ -15,7 +14,6 @@ import (
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/id"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -35,7 +33,7 @@ type App struct {
StatePath string
Config *opencode.Config
Client *opencode.Client
- State *config.State
+ State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
@@ -61,10 +59,7 @@ type ModelSelectedMsg struct {
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
-type SendMsg struct {
- Text string
- Attachments []opencode.FilePartInputParam
-}
+type SendPrompt = Prompt
type SetEditorContentMsg struct {
Text string
}
@@ -95,14 +90,14 @@ func New(
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
- appState, err := config.LoadState(appStatePath)
+ appState, err := LoadState(appStatePath)
if err != nil {
- appState = config.NewState()
- config.SaveState(appStatePath, appState)
+ appState = NewState()
+ SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
- appState.ModeModel = make(map[string]config.ModeModel)
+ appState.ModeModel = make(map[string]ModeModel)
}
if configInfo.Theme != "" {
@@ -127,7 +122,7 @@ func New(
mode = &modes[modeIndex]
if mode.Model.ModelID != "" {
- appState.ModeModel[mode.Name] = config.ModeModel{
+ appState.ModeModel[mode.Name] = ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
}
@@ -241,11 +236,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
}
a.State.Mode = a.Mode.Name
-
- return a, func() tea.Msg {
- a.SaveState()
- return nil
- }
+ return a, a.SaveState()
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
@@ -346,7 +337,7 @@ func (a *App) InitializeProvider() tea.Cmd {
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
- cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
+ cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
@@ -370,7 +361,6 @@ func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
-
lastMessage := a.Messages[len(a.Messages)-1]
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
@@ -378,10 +368,13 @@ func (a *App) IsBusy() bool {
return false
}
-func (a *App) SaveState() {
- err := config.SaveState(a.StatePath, a.State)
- if err != nil {
- slog.Error("Failed to save state", "error", err)
+func (a *App) SaveState() tea.Cmd {
+ return func() tea.Msg {
+ err := SaveState(a.StatePath, a.State)
+ if err != nil {
+ slog.Error("Failed to save state", "error", err)
+ }
+ return nil
}
}
@@ -459,11 +452,7 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil
}
-func (a *App) SendChatMessage(
- ctx context.Context,
- text string,
- attachments []opencode.FilePartInputParam,
-) (*App, tea.Cmd) {
+func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
@@ -474,65 +463,18 @@ func (a *App) SendChatMessage(
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
- message := opencode.UserMessage{
- ID: id.Ascending(id.Message),
- SessionID: a.Session.ID,
- Role: opencode.UserMessageRoleUser,
- Time: opencode.UserMessageTime{
- Created: float64(time.Now().UnixMilli()),
- },
- }
-
- parts := []opencode.PartUnion{opencode.TextPart{
- ID: id.Ascending(id.Part),
- MessageID: message.ID,
- SessionID: a.Session.ID,
- Type: opencode.TextPartTypeText,
- Text: text,
- }}
- if len(attachments) > 0 {
- for _, attachment := range attachments {
- parts = append(parts, opencode.FilePart{
- ID: id.Ascending(id.Part),
- MessageID: message.ID,
- SessionID: a.Session.ID,
- Type: opencode.FilePartTypeFile,
- Filename: attachment.Filename.Value,
- Mime: attachment.Mime.Value,
- URL: attachment.URL.Value,
- })
- }
- }
+ messageID := id.Ascending(id.Message)
+ message := prompt.ToMessage(messageID, a.Session.ID)
- a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
+ a.Messages = append(a.Messages, message)
cmds = append(cmds, func() tea.Msg {
- partsParam := []opencode.SessionChatParamsPartUnion{}
- for _, part := range parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- partsParam = append(partsParam, opencode.TextPartInputParam{
- ID: opencode.F(casted.ID),
- Type: opencode.F(opencode.TextPartInputType(casted.Type)),
- Text: opencode.F(casted.Text),
- })
- case opencode.FilePart:
- partsParam = append(partsParam, opencode.FilePartInputParam{
- ID: opencode.F(casted.ID),
- Mime: opencode.F(casted.Mime),
- Type: opencode.F(opencode.FilePartInputType(casted.Type)),
- URL: opencode.F(casted.URL),
- Filename: opencode.F(casted.Filename),
- })
- }
- }
-
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
- Parts: opencode.F(partsParam),
- MessageID: opencode.F(message.ID),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
Mode: opencode.F(a.Mode.Name),
+ MessageID: opencode.F(messageID),
+ Parts: opencode.F(message.ToSessionChatParams()),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
@@ -557,7 +499,6 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
- // status.Error(err.Error())
return err
}
return nil
diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go
new file mode 100644
index 000000000..dcd0b97fa
--- /dev/null
+++ b/packages/tui/internal/app/prompt.go
@@ -0,0 +1,210 @@
+package app
+
+import (
+ "time"
+
+ "github.com/sst/opencode-sdk-go"
+ "github.com/sst/opencode/internal/attachment"
+ "github.com/sst/opencode/internal/id"
+)
+
+type Prompt struct {
+ Text string `toml:"text"`
+ Attachments []*attachment.Attachment `toml:"attachments"`
+}
+
+func (p Prompt) ToMessage(
+ messageID string,
+ sessionID string,
+) Message {
+ message := opencode.UserMessage{
+ ID: messageID,
+ SessionID: sessionID,
+ Role: opencode.UserMessageRoleUser,
+ Time: opencode.UserMessageTime{
+ Created: float64(time.Now().UnixMilli()),
+ },
+ }
+ parts := []opencode.PartUnion{opencode.TextPart{
+ ID: id.Ascending(id.Part),
+ MessageID: messageID,
+ SessionID: sessionID,
+ Type: opencode.TextPartTypeText,
+ Text: p.Text,
+ }}
+ for _, attachment := range p.Attachments {
+ text := opencode.FilePartSourceText{
+ Start: int64(attachment.StartIndex),
+ End: int64(attachment.EndIndex),
+ Value: attachment.Display,
+ }
+ var source *opencode.FilePartSource
+ switch attachment.Type {
+ case "file":
+ fileSource, _ := attachment.GetFileSource()
+ source = &opencode.FilePartSource{
+ Text: text,
+ Path: fileSource.Path,
+ Type: opencode.FilePartSourceTypeFile,
+ }
+ case "symbol":
+ symbolSource, _ := attachment.GetSymbolSource()
+ source = &opencode.FilePartSource{
+ Text: text,
+ Path: symbolSource.Path,
+ Type: opencode.FilePartSourceTypeSymbol,
+ Kind: int64(symbolSource.Kind),
+ Name: symbolSource.Name,
+ Range: opencode.SymbolSourceRange{
+ Start: opencode.SymbolSourceRangeStart{
+ Line: float64(symbolSource.Range.Start.Line),
+ Character: float64(symbolSource.Range.Start.Char),
+ },
+ End: opencode.SymbolSourceRangeEnd{
+ Line: float64(symbolSource.Range.End.Line),
+ Character: float64(symbolSource.Range.End.Char),
+ },
+ },
+ }
+ }
+ parts = append(parts, opencode.FilePart{
+ ID: id.Ascending(id.Part),
+ MessageID: messageID,
+ SessionID: sessionID,
+ Type: opencode.FilePartTypeFile,
+ Filename: attachment.Filename,
+ Mime: attachment.MediaType,
+ URL: attachment.URL,
+ Source: *source,
+ })
+ }
+ return Message{
+ Info: message,
+ Parts: parts,
+ }
+}
+
+func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
+ parts := []opencode.SessionChatParamsPartUnion{}
+ for _, part := range m.Parts {
+ switch p := part.(type) {
+ case opencode.TextPart:
+ parts = append(parts, opencode.TextPartInputParam{
+ ID: opencode.F(p.ID),
+ Type: opencode.F(opencode.TextPartInputTypeText),
+ Text: opencode.F(p.Text),
+ Synthetic: opencode.F(p.Synthetic),
+ Time: opencode.F(opencode.TextPartInputTimeParam{
+ Start: opencode.F(p.Time.Start),
+ End: opencode.F(p.Time.End),
+ }),
+ })
+ case opencode.FilePart:
+ var source opencode.FilePartSourceUnionParam
+ switch p.Source.Type {
+ case "file":
+ source = opencode.FileSourceParam{
+ Type: opencode.F(opencode.FileSourceTypeFile),
+ Path: opencode.F(p.Source.Path),
+ Text: opencode.F(opencode.FilePartSourceTextParam{
+ Start: opencode.F(int64(p.Source.Text.Start)),
+ End: opencode.F(int64(p.Source.Text.End)),
+ Value: opencode.F(p.Source.Text.Value),
+ }),
+ }
+ case "symbol":
+ source = opencode.SymbolSourceParam{
+ Type: opencode.F(opencode.SymbolSourceTypeSymbol),
+ Path: opencode.F(p.Source.Path),
+ Name: opencode.F(p.Source.Name),
+ Kind: opencode.F(p.Source.Kind),
+ Range: opencode.F(opencode.SymbolSourceRangeParam{
+ Start: opencode.F(opencode.SymbolSourceRangeStartParam{
+ Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
+ Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
+ }),
+ End: opencode.F(opencode.SymbolSourceRangeEndParam{
+ Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
+ Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
+ }),
+ }),
+ Text: opencode.F(opencode.FilePartSourceTextParam{
+ Value: opencode.F(p.Source.Text.Value),
+ Start: opencode.F(p.Source.Text.Start),
+ End: opencode.F(p.Source.Text.End),
+ }),
+ }
+ }
+ parts = append(parts, opencode.FilePartInputParam{
+ ID: opencode.F(p.ID),
+ Type: opencode.F(opencode.FilePartInputTypeFile),
+ Mime: opencode.F(p.Mime),
+ URL: opencode.F(p.URL),
+ Filename: opencode.F(p.Filename),
+ Source: opencode.F(source),
+ })
+ }
+ }
+ return parts
+}
+
+func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
+ parts := []opencode.SessionChatParamsPartUnion{
+ opencode.TextPartInputParam{
+ Type: opencode.F(opencode.TextPartInputTypeText),
+ Text: opencode.F(p.Text),
+ },
+ }
+ for _, att := range p.Attachments {
+ filePart := opencode.FilePartInputParam{
+ Type: opencode.F(opencode.FilePartInputTypeFile),
+ Mime: opencode.F(att.MediaType),
+ URL: opencode.F(att.URL),
+ Filename: opencode.F(att.Filename),
+ }
+ switch att.Type {
+ case "file":
+ if fs, ok := att.GetFileSource(); ok {
+ filePart.Source = opencode.F(
+ opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
+ Type: opencode.F(opencode.FileSourceTypeFile),
+ Path: opencode.F(fs.Path),
+ Text: opencode.F(opencode.FilePartSourceTextParam{
+ Start: opencode.F(int64(att.StartIndex)),
+ End: opencode.F(int64(att.EndIndex)),
+ Value: opencode.F(att.Display),
+ }),
+ }),
+ )
+ }
+ case "symbol":
+ if ss, ok := att.GetSymbolSource(); ok {
+ filePart.Source = opencode.F(
+ opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
+ Type: opencode.F(opencode.SymbolSourceTypeSymbol),
+ Path: opencode.F(ss.Path),
+ Name: opencode.F(ss.Name),
+ Kind: opencode.F(int64(ss.Kind)),
+ Range: opencode.F(opencode.SymbolSourceRangeParam{
+ Start: opencode.F(opencode.SymbolSourceRangeStartParam{
+ Line: opencode.F(float64(ss.Range.Start.Line)),
+ Character: opencode.F(float64(ss.Range.Start.Char)),
+ }),
+ End: opencode.F(opencode.SymbolSourceRangeEndParam{
+ Line: opencode.F(float64(ss.Range.End.Line)),
+ Character: opencode.F(float64(ss.Range.End.Char)),
+ }),
+ }),
+ Text: opencode.F(opencode.FilePartSourceTextParam{
+ Start: opencode.F(int64(att.StartIndex)),
+ End: opencode.F(int64(att.EndIndex)),
+ Value: opencode.F(att.Display),
+ }),
+ }),
+ )
+ }
+ }
+ parts = append(parts, filePart)
+ }
+ return parts
+}
diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/app/state.go
index d20376dd8..760a21623 100644
--- a/packages/tui/internal/config/config.go
+++ b/packages/tui/internal/app/state.go
@@ -1,4 +1,4 @@
-package config
+package app
import (
"bufio"
@@ -30,6 +30,7 @@ type State struct {
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
+ MessageHistory []Prompt `toml:"message_history"`
}
func NewState() *State {
@@ -38,6 +39,7 @@ func NewState() *State {
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
+ MessageHistory: make([]Prompt, 0),
}
}
@@ -78,6 +80,13 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
}
}
+func (s *State) AddPromptToHistory(prompt Prompt) {
+ s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
+ if len(s.MessageHistory) > 50 {
+ s.MessageHistory = s.MessageHistory[:50]
+ }
+}
+
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveState(filePath string, state *State) error {
diff --git a/packages/tui/internal/attachment/attachment.go b/packages/tui/internal/attachment/attachment.go
new file mode 100644
index 000000000..178de9954
--- /dev/null
+++ b/packages/tui/internal/attachment/attachment.go
@@ -0,0 +1,65 @@
+package attachment
+
+import (
+ "github.com/google/uuid"
+)
+
+type FileSource struct {
+ Path string `toml:"path"`
+ Mime string `toml:"mime"`
+ Data []byte `toml:"data,omitempty"` // Optional for image data
+}
+
+type SymbolSource struct {
+ Path string `toml:"path"`
+ Name string `toml:"name"`
+ Kind int `toml:"kind"`
+ Range SymbolRange `toml:"range"`
+}
+
+type SymbolRange struct {
+ Start Position `toml:"start"`
+ End Position `toml:"end"`
+}
+
+type Position struct {
+ Line int `toml:"line"`
+ Char int `toml:"char"`
+}
+
+type Attachment struct {
+ ID string `toml:"id"`
+ Type string `toml:"type"`
+ Display string `toml:"display"`
+ URL string `toml:"url"`
+ Filename string `toml:"filename"`
+ MediaType string `toml:"media_type"`
+ StartIndex int `toml:"start_index"`
+ EndIndex int `toml:"end_index"`
+ Source any `toml:"source,omitempty"`
+}
+
+// NewAttachment creates a new attachment with a unique ID
+func NewAttachment() *Attachment {
+ return &Attachment{
+ ID: uuid.NewString(),
+ }
+}
+
+// GetFileSource returns the source as FileSource if the attachment is a file type
+func (a *Attachment) GetFileSource() (*FileSource, bool) {
+ if a.Type != "file" {
+ return nil, false
+ }
+ fs, ok := a.Source.(*FileSource)
+ return fs, ok
+}
+
+// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
+func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
+ if a.Type != "symbol" {
+ return nil, false
+ }
+ ss, ok := a.Source.(*SymbolSource)
+ return ss, ok
+}
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)
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 901cee11d..6ff8f9282 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -25,7 +25,6 @@ import (
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -331,9 +330,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case error:
return a, toast.NewErrorToast(msg.Error())
- case app.SendMsg:
+ case app.SendPrompt:
a.showCompletionDialog = false
- a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
+ a.app, cmd = a.app.SendPrompt(context.Background(), msg)
cmds = append(cmds, cmd)
case app.SetEditorContentMsg:
// Set the editor content without sending
@@ -467,15 +466,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
- a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
+ a.app.State.ModeModel[a.app.Mode.Name] = app.ModeModel{
ProviderID: msg.Provider.ID,
ModelID: msg.Model.ID,
}
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
- a.app.SaveState()
+ cmds = append(cmds, a.app.SaveState())
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
- a.app.SaveState()
+ cmds = append(cmds, a.app.SaveState())
case toast.ShowToastMsg:
tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
@@ -928,9 +927,9 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
cmds = append(cmds, cmd)
case commands.FileDiffToggleCommand:
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
- a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
- a.app.SaveState()
cmds = append(cmds, cmd)
+ a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
+ cmds = append(cmds, a.app.SaveState())
case commands.FileSearchCommand:
return a, nil
case commands.ProjectInitCommand:
@@ -1001,7 +1000,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.MessagesLayoutToggleCommand:
a.messagesRight = !a.messagesRight
a.app.State.MessagesRight = a.messagesRight
- a.app.SaveState()
+ cmds = append(cmds, a.app.SaveState())
case commands.MessagesCopyCommand:
updated, cmd := a.messages.CopyLastMessage()
a.messages = updated.(chat.MessagesComponent)