Browse Source

feat(tui): message history select with up/down arrows

adamdotdevin 7 months ago
parent
commit
8e8796507d

+ 22 - 81
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

+ 210 - 0
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
+}

+ 10 - 1
packages/tui/internal/config/config.go → 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 {

+ 65 - 0
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
+}

+ 142 - 22
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,
+		},
 	}
 }

+ 6 - 2
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 {

+ 3 - 2
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
 		}
 	}
 

+ 49 - 33
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)
 		}
 

+ 8 - 9
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)