summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/app
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-21 05:52:02 -0500
committeradamdotdevin <[email protected]>2025-07-21 05:52:11 -0500
commit8e8796507d9adcb89341dfe01ec499938611ebea (patch)
treea4d503f71eed1280a478b1adaf2037a88594eac3 /packages/tui/internal/app
parentcef5c295834760d9d3a57334f2e52bd528c66e68 (diff)
downloadopencode-8e8796507d9adcb89341dfe01ec499938611ebea.tar.gz
opencode-8e8796507d9adcb89341dfe01ec499938611ebea.zip
feat(tui): message history select with up/down arrows
Diffstat (limited to 'packages/tui/internal/app')
-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.go123
3 files changed, 355 insertions, 81 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/app/state.go b/packages/tui/internal/app/state.go
new file mode 100644
index 000000000..760a21623
--- /dev/null
+++ b/packages/tui/internal/app/state.go
@@ -0,0 +1,123 @@
+package app
+
+import (
+ "bufio"
+ "fmt"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/BurntSushi/toml"
+)
+
+type ModelUsage struct {
+ ProviderID string `toml:"provider_id"`
+ ModelID string `toml:"model_id"`
+ LastUsed time.Time `toml:"last_used"`
+}
+
+type ModeModel struct {
+ ProviderID string `toml:"provider_id"`
+ ModelID string `toml:"model_id"`
+}
+
+type State struct {
+ Theme string `toml:"theme"`
+ ModeModel map[string]ModeModel `toml:"mode_model"`
+ Provider string `toml:"provider"`
+ Model string `toml:"model"`
+ Mode string `toml:"mode"`
+ RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
+ MessagesRight bool `toml:"messages_right"`
+ SplitDiff bool `toml:"split_diff"`
+ MessageHistory []Prompt `toml:"message_history"`
+}
+
+func NewState() *State {
+ return &State{
+ Theme: "opencode",
+ Mode: "build",
+ ModeModel: make(map[string]ModeModel),
+ RecentlyUsedModels: make([]ModelUsage, 0),
+ MessageHistory: make([]Prompt, 0),
+ }
+}
+
+// UpdateModelUsage updates the recently used models list with the specified model
+func (s *State) UpdateModelUsage(providerID, modelID string) {
+ now := time.Now()
+
+ // Check if this model is already in the list
+ for i, usage := range s.RecentlyUsedModels {
+ if usage.ProviderID == providerID && usage.ModelID == modelID {
+ s.RecentlyUsedModels[i].LastUsed = now
+ usage := s.RecentlyUsedModels[i]
+ copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
+ s.RecentlyUsedModels[0] = usage
+ return
+ }
+ }
+
+ newUsage := ModelUsage{
+ ProviderID: providerID,
+ ModelID: modelID,
+ LastUsed: now,
+ }
+
+ // Prepend to slice and limit to last 50 entries
+ s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
+ if len(s.RecentlyUsedModels) > 50 {
+ s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
+ }
+}
+
+func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
+ for i, usage := range s.RecentlyUsedModels {
+ if usage.ProviderID == providerID && usage.ModelID == modelID {
+ s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
+ return
+ }
+ }
+}
+
+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 {
+ file, err := os.Create(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
+ }
+ defer file.Close()
+
+ writer := bufio.NewWriter(file)
+ encoder := toml.NewEncoder(writer)
+ if err := encoder.Encode(state); err != nil {
+ return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
+ }
+ if err := writer.Flush(); err != nil {
+ return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
+ }
+
+ slog.Debug("State saved to file", "file", filePath)
+ return nil
+}
+
+// LoadState loads the state from the specified TOML file.
+// It returns a pointer to the State struct and an error if any issues occur.
+func LoadState(filePath string) (*State, error) {
+ var state State
+ if _, err := toml.DecodeFile(filePath, &state); err != nil {
+ if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
+ return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
+ }
+ return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
+ }
+ return &state, nil
+}