diff options
| author | adamdotdevin <[email protected]> | 2025-07-21 05:52:02 -0500 |
|---|---|---|
| committer | adamdotdevin <[email protected]> | 2025-07-21 05:52:11 -0500 |
| commit | 8e8796507d9adcb89341dfe01ec499938611ebea (patch) | |
| tree | a4d503f71eed1280a478b1adaf2037a88594eac3 /packages/tui/internal/app | |
| parent | cef5c295834760d9d3a57334f2e52bd528c66e68 (diff) | |
| download | opencode-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.go | 103 | ||||
| -rw-r--r-- | packages/tui/internal/app/prompt.go | 210 | ||||
| -rw-r--r-- | packages/tui/internal/app/state.go | 123 |
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 +} |
