diff options
Diffstat (limited to 'packages/tui/internal/app')
| -rw-r--r-- | packages/tui/internal/app/app.go | 963 | ||||
| -rw-r--r-- | packages/tui/internal/app/app_test.go | 304 | ||||
| -rw-r--r-- | packages/tui/internal/app/prompt.go | 283 | ||||
| -rw-r--r-- | packages/tui/internal/app/state.go | 174 |
4 files changed, 0 insertions, 1724 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go deleted file mode 100644 index e0f1d9920..000000000 --- a/packages/tui/internal/app/app.go +++ /dev/null @@ -1,963 +0,0 @@ -package app - -import ( - "context" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "time" - - "log/slog" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/clipboard" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/toast" - "github.com/sst/opencode/internal/id" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -type Message struct { - Info opencode.MessageUnion - Parts []opencode.PartUnion -} - -type App struct { - Project opencode.Project - Agents []opencode.Agent - Providers []opencode.Provider - Version string - StatePath string - Config *opencode.Config - Client *opencode.Client - State *State - AgentIndex int - Provider *opencode.Provider - Model *opencode.Model - Session *opencode.Session - Messages []Message - Permissions []opencode.Permission - CurrentPermission opencode.Permission - Commands commands.CommandRegistry - InitialModel *string - InitialPrompt *string - InitialAgent *string - InitialSession *string - compactCancel context.CancelFunc - IsLeaderSequence bool - IsBashMode bool - ScrollSpeed int -} - -func (a *App) Agent() *opencode.Agent { - return &a.Agents[a.AgentIndex] -} - -type SessionCreatedMsg = struct { - Session *opencode.Session -} -type SessionSelectedMsg = *opencode.Session -type MessageRevertedMsg struct { - Session opencode.Session - Message Message -} -type SessionUnrevertedMsg struct { - Session opencode.Session -} -type SessionLoadedMsg struct{} -type ModelSelectedMsg struct { - Provider opencode.Provider - Model opencode.Model -} - -type AgentSelectedMsg struct { - AgentName string -} - -type SessionClearedMsg struct{} -type CompactSessionMsg struct{} -type SendPrompt = Prompt -type SendShell = struct { - Command string -} -type SendCommand = struct { - Command string - Args string -} -type SetEditorContentMsg struct { - Text string -} -type FileRenderedMsg struct { - FilePath string -} -type PermissionRespondedToMsg struct { - Response opencode.SessionPermissionRespondParamsResponse -} - -func New( - ctx context.Context, - version string, - project *opencode.Project, - path *opencode.Path, - agents []opencode.Agent, - httpClient *opencode.Client, - initialModel *string, - initialPrompt *string, - initialAgent *string, - initialSession *string, -) (*App, error) { - util.RootPath = project.Worktree - util.CwdPath, _ = os.Getwd() - - configInfo, err := httpClient.Config.Get(ctx, opencode.ConfigGetParams{}) - if err != nil { - return nil, err - } - - if configInfo.Keybinds.Leader == "" { - configInfo.Keybinds.Leader = "ctrl+x" - } - - appStatePath := filepath.Join(path.State, "tui") - appState, err := LoadState(appStatePath) - if err != nil { - appState = NewState() - SaveState(appStatePath, appState) - } - - if appState.AgentModel == nil { - appState.AgentModel = make(map[string]AgentModel) - } - - if configInfo.Theme != "" { - appState.Theme = configInfo.Theme - } - - themeEnv := os.Getenv("OPENCODE_THEME") - if themeEnv != "" { - appState.Theme = themeEnv - } - - agentIndex := slices.IndexFunc(agents, func(a opencode.Agent) bool { - return a.Mode != "subagent" - }) - var agent *opencode.Agent - modeName := "build" - if appState.Agent != "" { - modeName = appState.Agent - } - if initialAgent != nil && *initialAgent != "" { - modeName = *initialAgent - } - for i, m := range agents { - if m.Name == modeName { - agentIndex = i - break - } - } - agent = &agents[agentIndex] - - if agent.Model.ModelID != "" { - appState.AgentModel[agent.Name] = AgentModel{ - ProviderID: agent.Model.ProviderID, - ModelID: agent.Model.ModelID, - } - } - - if err := theme.LoadThemesFromDirectories( - path.Config, - util.RootPath, - util.CwdPath, - ); err != nil { - slog.Warn("Failed to load themes from directories", "error", err) - } - - if appState.Theme != "" { - if appState.Theme == "system" && styles.Terminal != nil { - theme.UpdateSystemTheme( - styles.Terminal.Background, - styles.Terminal.BackgroundIsDark, - ) - } - theme.SetTheme(appState.Theme) - } - - slog.Debug("Loaded config", "config", configInfo) - - customCommands, err := httpClient.Command.List(ctx, opencode.CommandListParams{}) - if err != nil { - return nil, err - } - - app := &App{ - Project: *project, - Agents: agents, - Version: version, - StatePath: appStatePath, - Config: configInfo, - State: appState, - Client: httpClient, - AgentIndex: agentIndex, - Session: &opencode.Session{}, - Messages: []Message{}, - Commands: commands.LoadFromConfig(configInfo, *customCommands), - InitialModel: initialModel, - InitialPrompt: initialPrompt, - InitialAgent: initialAgent, - InitialSession: initialSession, - ScrollSpeed: int(configInfo.Tui.ScrollSpeed), - } - - return app, nil -} - -func (a *App) Keybind(commandName commands.CommandName) string { - command := a.Commands[commandName] - if len(command.Keybindings) == 0 { - return "" - } - kb := command.Keybindings[0] - key := kb.Key - if kb.RequiresLeader { - key = a.Config.Keybinds.Leader + " " + kb.Key - } - return key -} - -func (a *App) Key(commandName commands.CommandName) string { - t := theme.CurrentTheme() - base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render - muted := styles.NewStyle(). - Background(t.Background()). - Foreground(t.TextMuted()). - Faint(true). - Render - command := a.Commands[commandName] - key := a.Keybind(commandName) - return base(key) + muted(" "+command.Description) -} - -func SetClipboard(text string) tea.Cmd { - var cmds []tea.Cmd - cmds = append(cmds, func() tea.Msg { - clipboard.Write(clipboard.FmtText, []byte(text)) - return nil - }) - // try to set the clipboard using OSC52 for terminals that support it - cmds = append(cmds, tea.SetClipboard(text)) - return tea.Sequence(cmds...) -} - -func (a *App) updateModelForNewAgent() { - singleModelEnv := os.Getenv("OPENCODE_AGENTS_SWITCH_SINGLE_MODEL") - isSingleModel := singleModelEnv == "1" || singleModelEnv == "true" - - if isSingleModel { - return - } - // Set up model for the new agent - modelID := a.Agent().Model.ModelID - providerID := a.Agent().Model.ProviderID - if modelID == "" { - if model, ok := a.State.AgentModel[a.Agent().Name]; ok { - modelID = model.ModelID - providerID = model.ProviderID - } - } - - if modelID != "" { - for _, provider := range a.Providers { - if provider.ID == providerID { - a.Provider = &provider - for _, model := range provider.Models { - if model.ID == modelID { - a.Model = &model - break - } - } - break - } - } - } -} - -func (a *App) cycleMode(forward bool) (*App, tea.Cmd) { - if forward { - a.AgentIndex++ - if a.AgentIndex >= len(a.Agents) { - a.AgentIndex = 0 - } - } else { - a.AgentIndex-- - if a.AgentIndex < 0 { - a.AgentIndex = len(a.Agents) - 1 - } - } - if a.Agent().Mode == "subagent" { - return a.cycleMode(forward) - } - - a.updateModelForNewAgent() - - a.State.Agent = a.Agent().Name - a.State.UpdateAgentUsage(a.Agent().Name) - return a, a.SaveState() -} - -func (a *App) SwitchAgent() (*App, tea.Cmd) { - return a.cycleMode(true) -} - -func (a *App) SwitchAgentReverse() (*App, tea.Cmd) { - return a.cycleMode(false) -} - -func (a *App) cycleRecentModel(forward bool) (*App, tea.Cmd) { - recentModels := a.State.RecentlyUsedModels - if len(recentModels) > 5 { - recentModels = recentModels[:5] - } - if len(recentModels) < 2 { - return a, toast.NewInfoToast("Need at least 2 recent models to cycle") - } - nextIndex := 0 - prevIndex := 0 - for i, recentModel := range recentModels { - if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID && - recentModel.ModelID == a.Model.ID { - nextIndex = (i + 1) % len(recentModels) - prevIndex = (i - 1 + len(recentModels)) % len(recentModels) - break - } - } - targetIndex := nextIndex - if !forward { - targetIndex = prevIndex - } - for range recentModels { - currentRecentModel := recentModels[targetIndex%len(recentModels)] - provider, model := findModelByProviderAndModelID( - a.Providers, - currentRecentModel.ProviderID, - currentRecentModel.ModelID, - ) - if provider != nil && model != nil { - a.Provider, a.Model = provider, model - a.State.AgentModel[a.Agent().Name] = AgentModel{ - ProviderID: provider.ID, - ModelID: model.ID, - } - return a, tea.Sequence( - a.SaveState(), - toast.NewSuccessToast( - fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name), - ), - ) - } - recentModels = append( - recentModels[:targetIndex%len(recentModels)], - recentModels[targetIndex%len(recentModels)+1:]...) - if len(recentModels) < 2 { - a.State.RecentlyUsedModels = recentModels - return a, tea.Sequence( - a.SaveState(), - toast.NewInfoToast("Not enough valid recent models to cycle"), - ) - } - } - a.State.RecentlyUsedModels = recentModels - return a, toast.NewErrorToast("Recent model not found") -} - -func (a *App) CycleRecentModel() (*App, tea.Cmd) { - return a.cycleRecentModel(true) -} - -func (a *App) CycleRecentModelReverse() (*App, tea.Cmd) { - return a.cycleRecentModel(false) -} - -func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) { - // Find the agent index by name - for i, agent := range a.Agents { - if agent.Name == agentName { - a.AgentIndex = i - break - } - } - - a.updateModelForNewAgent() - - a.State.Agent = a.Agent().Name - a.State.UpdateAgentUsage(agentName) - return a, a.SaveState() -} - -// findModelByFullID finds a model by its full ID in the format "provider/model" -func findModelByFullID( - providers []opencode.Provider, - fullModelID string, -) (*opencode.Provider, *opencode.Model) { - modelParts := strings.SplitN(fullModelID, "/", 2) - if len(modelParts) < 2 { - return nil, nil - } - - providerID := modelParts[0] - modelID := modelParts[1] - - return findModelByProviderAndModelID(providers, providerID, modelID) -} - -// findModelByProviderAndModelID finds a model by provider ID and model ID -func findModelByProviderAndModelID( - providers []opencode.Provider, - providerID, modelID string, -) (*opencode.Provider, *opencode.Model) { - for _, provider := range providers { - if provider.ID != providerID { - continue - } - - for _, model := range provider.Models { - if model.ID == modelID { - return &provider, &model - } - } - - // Provider found but model not found - return nil, nil - } - - // Provider not found - return nil, nil -} - -// findProviderByID finds a provider by its ID -func findProviderByID(providers []opencode.Provider, providerID string) *opencode.Provider { - for _, provider := range providers { - if provider.ID == providerID { - return &provider - } - } - return nil -} - -func (a *App) InitializeProvider() tea.Cmd { - providersResponse, err := a.Client.App.Providers(context.Background(), opencode.AppProvidersParams{}) - if err != nil { - slog.Error("Failed to list providers", "error", err) - // TODO: notify user - return nil - } - providers := providersResponse.Providers - if len(providers) == 0 { - slog.Error("No providers configured") - return nil - } - - a.Providers = providers - - // retains backwards compatibility with old state format - if model, ok := a.State.AgentModel[a.State.Agent]; ok { - a.State.Provider = model.ProviderID - a.State.Model = model.ModelID - } - - var selectedProvider *opencode.Provider - var selectedModel *opencode.Model - - // Priority 1: Command line --model flag (InitialModel) - if a.InitialModel != nil && *a.InitialModel != "" { - if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug( - "Selected model from command line", - "provider", - provider.ID, - "model", - model.ID, - ) - } else { - slog.Debug("Command line model not found", "model", *a.InitialModel) - } - } - - // Priority 2: Current agent's preferred model - if selectedProvider == nil && a.Agent().Model.ModelID != "" { - if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug( - "Selected model from current agent", - "provider", - provider.ID, - "model", - model.ID, - "agent", - a.Agent().Name, - ) - } else { - slog.Debug("Agent model not found", "provider", a.Agent().Model.ProviderID, "model", a.Agent().Model.ModelID, "agent", a.Agent().Name) - } - } - - // Priority 3: Config file model setting - if selectedProvider == nil && a.Config.Model != "" { - if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID) - } else { - slog.Debug("Config model not found", "model", a.Config.Model) - } - } - - // Priority 4: Recent model usage (most recently used model) - if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 { - recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first - if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug( - "Selected model from recent usage", - "provider", - provider.ID, - "model", - model.ID, - ) - } else { - slog.Debug("Recent model not found", "provider", recentUsage.ProviderID, "model", recentUsage.ModelID) - } - } - - // Priority 5: State-based model (backwards compatibility) - if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" { - if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug("Selected model from state", "provider", provider.ID, "model", model.ID) - } else { - slog.Debug("State model not found", "provider", a.State.Provider, "model", a.State.Model) - } - } - - // Priority 6: Internal priority fallback (Anthropic preferred, then first available) - if selectedProvider == nil { - // Try Anthropic first as internal priority - if provider := findProviderByID(providers, "anthropic"); provider != nil { - if model := getDefaultModel(providersResponse, *provider); model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug( - "Selected model from internal priority (Anthropic)", - "provider", - provider.ID, - "model", - model.ID, - ) - } - } - - // If Anthropic not available, use first available provider - if selectedProvider == nil && len(providers) > 0 { - provider := &providers[0] - if model := getDefaultModel(providersResponse, *provider); model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug( - "Selected model from fallback (first available)", - "provider", - provider.ID, - "model", - model.ID, - ) - } - } - } - - // Final safety check - if selectedProvider == nil || selectedModel == nil { - slog.Error("Failed to select any model") - return nil - } - - var cmds []tea.Cmd - cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{ - Provider: *selectedProvider, - Model: *selectedModel, - })) - - // Load initial session if provided - if a.InitialSession != nil && *a.InitialSession != "" { - cmds = append(cmds, func() tea.Msg { - // Find the session by ID - sessions, err := a.ListSessions(context.Background()) - if err != nil { - slog.Error("Failed to list sessions for initial session", "error", err) - return toast.NewErrorToast("Failed to load initial session")() - } - - for _, session := range sessions { - if session.ID == *a.InitialSession { - return SessionSelectedMsg(&session) - } - } - - slog.Warn("Initial session not found", "sessionID", *a.InitialSession) - return toast.NewErrorToast("Session not found: " + *a.InitialSession)() - }) - } - - if a.InitialPrompt != nil && *a.InitialPrompt != "" { - cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt})) - } - return tea.Sequence(cmds...) -} - -func getDefaultModel( - response *opencode.AppProvidersResponse, - provider opencode.Provider, -) *opencode.Model { - if match, ok := response.Default[provider.ID]; ok { - model := provider.Models[match] - return &model - } else { - for _, model := range provider.Models { - return &model - } - } - return nil -} - -func (a *App) IsBusy() bool { - if len(a.Messages) == 0 { - return false - } - if a.IsCompacting() { - return true - } - lastMessage := a.Messages[len(a.Messages)-1] - if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok { - return casted.Time.Completed == 0 - } - return false -} - -func (a *App) IsCompacting() bool { - if time.Since(time.UnixMilli(int64(a.Session.Time.Compacting))) < time.Second*30 { - return true - } - return false -} - -func (a *App) HasAnimatingWork() bool { - for _, msg := range a.Messages { - switch casted := msg.Info.(type) { - case opencode.AssistantMessage: - if casted.Time.Completed == 0 { - return true - } - } - for _, p := range msg.Parts { - if tp, ok := p.(opencode.ToolPart); ok { - if tp.State.Status == opencode.ToolPartStateStatusPending { - return true - } - } - } - } - return false -} - -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 - } -} - -func (a *App) InitializeProject(ctx context.Context) tea.Cmd { - cmds := []tea.Cmd{} - - session, err := a.CreateSession(ctx) - if err != nil { - // status.Error(err.Error()) - return nil - } - - a.Session = session - cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) - - go func() { - _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{ - MessageID: opencode.F(id.Ascending(id.Message)), - ProviderID: opencode.F(a.Provider.ID), - ModelID: opencode.F(a.Model.ID), - }) - if err != nil { - slog.Error("Failed to initialize project", "error", err) - // status.Error(err.Error()) - } - }() - - return tea.Batch(cmds...) -} - -func (a *App) CompactSession(ctx context.Context) tea.Cmd { - if a.compactCancel != nil { - a.compactCancel() - } - - compactCtx, cancel := context.WithCancel(ctx) - a.compactCancel = cancel - - go func() { - defer func() { - a.compactCancel = nil - }() - - _, err := a.Client.Session.Summarize( - compactCtx, - a.Session.ID, - opencode.SessionSummarizeParams{ - ProviderID: opencode.F(a.Provider.ID), - ModelID: opencode.F(a.Model.ID), - }, - ) - if err != nil { - if compactCtx.Err() != context.Canceled { - slog.Error("Failed to compact session", "error", err) - } - } - }() - return nil -} - -func (a *App) MarkProjectInitialized(ctx context.Context) error { - return nil - /* - _, err := a.Client.App.Init(ctx) - if err != nil { - slog.Error("Failed to mark project as initialized", "error", err) - return err - } - return nil - */ -} - -func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { - session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{}) - if err != nil { - return nil, err - } - return session, nil -} - -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) - if err != nil { - return a, toast.NewErrorToast(err.Error()) - } - a.Session = session - cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) - } - - messageID := id.Ascending(id.Message) - message := prompt.ToMessage(messageID, a.Session.ID) - - a.Messages = append(a.Messages, message) - - cmds = append(cmds, func() tea.Msg { - _, err := a.Client.Session.Prompt(ctx, a.Session.ID, opencode.SessionPromptParams{ - Model: opencode.F(opencode.SessionPromptParamsModel{ - ProviderID: opencode.F(a.Provider.ID), - ModelID: opencode.F(a.Model.ID), - }), - Agent: opencode.F(a.Agent().Name), - MessageID: opencode.F(messageID), - Parts: opencode.F(message.ToSessionChatParams()), - }) - if err != nil { - errormsg := fmt.Sprintf("failed to send message: %v", err) - slog.Error(errormsg) - return toast.NewErrorToast(errormsg)() - } - return nil - }) - - // The actual response will come through SSE - // For now, just return success - return a, tea.Batch(cmds...) -} - -func (a *App) SendCommand(ctx context.Context, command string, args string) (*App, tea.Cmd) { - var cmds []tea.Cmd - if a.Session.ID == "" { - session, err := a.CreateSession(ctx) - if err != nil { - return a, toast.NewErrorToast(err.Error()) - } - a.Session = session - cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) - } - - cmds = append(cmds, func() tea.Msg { - params := opencode.SessionCommandParams{ - Command: opencode.F(command), - Arguments: opencode.F(args), - Agent: opencode.F(a.Agents[a.AgentIndex].Name), - } - if a.Provider != nil && a.Model != nil { - params.Model = opencode.F(a.Provider.ID + "/" + a.Model.ID) - } - _, err := a.Client.Session.Command( - context.Background(), - a.Session.ID, - params, - ) - if err != nil { - slog.Error("Failed to execute command", "error", err) - return toast.NewErrorToast(fmt.Sprintf("Failed to execute command: %v", err))() - } - return nil - }) - - // The actual response will come through SSE - // For now, just return success - return a, tea.Batch(cmds...) -} - -func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) { - var cmds []tea.Cmd - if a.Session.ID == "" { - session, err := a.CreateSession(ctx) - if err != nil { - return a, toast.NewErrorToast(err.Error()) - } - a.Session = session - cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) - } - - cmds = append(cmds, func() tea.Msg { - _, err := a.Client.Session.Shell( - context.Background(), - a.Session.ID, - opencode.SessionShellParams{ - Agent: opencode.F(a.Agent().Name), - Command: opencode.F(command), - }, - ) - if err != nil { - slog.Error("Failed to submit shell command", "error", err) - return toast.NewErrorToast(fmt.Sprintf("Failed to submit shell command: %v", err))() - } - return nil - }) - - // The actual response will come through SSE - // For now, just return success - return a, tea.Batch(cmds...) -} - -func (a *App) Cancel(ctx context.Context, sessionID string) error { - // Cancel any running compact operation - if a.compactCancel != nil { - a.compactCancel() - a.compactCancel = nil - } - - _, err := a.Client.Session.Abort(ctx, sessionID, opencode.SessionAbortParams{}) - if err != nil { - slog.Error("Failed to cancel session", "error", err) - return err - } - return nil -} - -func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) { - response, err := a.Client.Session.List(ctx, opencode.SessionListParams{}) - if err != nil { - return nil, err - } - if response == nil { - return []opencode.Session{}, nil - } - sessions := *response - return sessions, nil -} - -func (a *App) DeleteSession(ctx context.Context, sessionID string) error { - _, err := a.Client.Session.Delete(ctx, sessionID, opencode.SessionDeleteParams{}) - if err != nil { - slog.Error("Failed to delete session", "error", err) - return err - } - return nil -} - -func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error { - _, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{ - Title: opencode.F(title), - }) - if err != nil { - slog.Error("Failed to update session", "error", err) - return err - } - return nil -} - -func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) { - response, err := a.Client.Session.Messages(ctx, sessionId, opencode.SessionMessagesParams{}) - if err != nil { - return nil, err - } - if response == nil { - return []Message{}, nil - } - messages := []Message{} - for _, message := range *response { - msg := Message{ - Info: message.Info.AsUnion(), - Parts: []opencode.PartUnion{}, - } - for _, part := range message.Parts { - msg.Parts = append(msg.Parts, part.AsUnion()) - } - messages = append(messages, msg) - } - return messages, nil -} - -func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) { - response, err := a.Client.App.Providers(ctx, opencode.AppProvidersParams{}) - if err != nil { - return nil, err - } - if response == nil { - return []opencode.Provider{}, nil - } - - providers := *response - return providers.Providers, nil -} - -// func (a *App) loadCustomKeybinds() { -// -// } diff --git a/packages/tui/internal/app/app_test.go b/packages/tui/internal/app/app_test.go deleted file mode 100644 index e716d4376..000000000 --- a/packages/tui/internal/app/app_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package app - -import ( - "testing" - - "github.com/sst/opencode-sdk-go" -) - -// TestFindModelByFullID tests the findModelByFullID function -func TestFindModelByFullID(t *testing.T) { - // Create test providers with models - providers := []opencode.Provider{ - { - ID: "anthropic", - Models: map[string]opencode.Model{ - "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"}, - "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"}, - }, - }, - { - ID: "openai", - Models: map[string]opencode.Model{ - "gpt-4": {ID: "gpt-4"}, - "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"}, - }, - }, - } - - tests := []struct { - name string - fullModelID string - expectedFound bool - expectedProviderID string - expectedModelID string - }{ - { - name: "valid full model ID", - fullModelID: "anthropic/claude-3-opus-20240229", - expectedFound: true, - expectedProviderID: "anthropic", - expectedModelID: "claude-3-opus-20240229", - }, - { - name: "valid full model ID with slash in model name", - fullModelID: "openai/gpt-3.5-turbo", - expectedFound: true, - expectedProviderID: "openai", - expectedModelID: "gpt-3.5-turbo", - }, - { - name: "invalid format - missing slash", - fullModelID: "anthropic", - expectedFound: false, - }, - { - name: "invalid format - empty string", - fullModelID: "", - expectedFound: false, - }, - { - name: "provider not found", - fullModelID: "nonexistent/model", - expectedFound: false, - }, - { - name: "model not found", - fullModelID: "anthropic/nonexistent-model", - expectedFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, model := findModelByFullID(providers, tt.fullModelID) - - if tt.expectedFound { - if provider == nil || model == nil { - t.Errorf("Expected to find provider/model, but got nil") - return - } - - if provider.ID != tt.expectedProviderID { - t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID) - } - - if model.ID != tt.expectedModelID { - t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID) - } - } else { - if provider != nil || model != nil { - t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model) - } - } - }) - } -} - -// TestFindModelByProviderAndModelID tests the findModelByProviderAndModelID function -func TestFindModelByProviderAndModelID(t *testing.T) { - // Create test providers with models - providers := []opencode.Provider{ - { - ID: "anthropic", - Models: map[string]opencode.Model{ - "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"}, - "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"}, - }, - }, - { - ID: "openai", - Models: map[string]opencode.Model{ - "gpt-4": {ID: "gpt-4"}, - "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"}, - }, - }, - } - - tests := []struct { - name string - providerID string - modelID string - expectedFound bool - expectedProviderID string - expectedModelID string - }{ - { - name: "valid provider and model", - providerID: "anthropic", - modelID: "claude-3-opus-20240229", - expectedFound: true, - expectedProviderID: "anthropic", - expectedModelID: "claude-3-opus-20240229", - }, - { - name: "provider not found", - providerID: "nonexistent", - modelID: "claude-3-opus-20240229", - expectedFound: false, - }, - { - name: "model not found", - providerID: "anthropic", - modelID: "nonexistent-model", - expectedFound: false, - }, - { - name: "both provider and model not found", - providerID: "nonexistent", - modelID: "nonexistent-model", - expectedFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, model := findModelByProviderAndModelID(providers, tt.providerID, tt.modelID) - - if tt.expectedFound { - if provider == nil || model == nil { - t.Errorf("Expected to find provider/model, but got nil") - return - } - - if provider.ID != tt.expectedProviderID { - t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID) - } - - if model.ID != tt.expectedModelID { - t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID) - } - } else { - if provider != nil || model != nil { - t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model) - } - } - }) - } -} - -// TestFindProviderByID tests the findProviderByID function -func TestFindProviderByID(t *testing.T) { - // Create test providers - providers := []opencode.Provider{ - {ID: "anthropic"}, - {ID: "openai"}, - {ID: "google"}, - } - - tests := []struct { - name string - providerID string - expectedFound bool - expectedProviderID string - }{ - { - name: "provider found", - providerID: "anthropic", - expectedFound: true, - expectedProviderID: "anthropic", - }, - { - name: "provider not found", - providerID: "nonexistent", - expectedFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := findProviderByID(providers, tt.providerID) - - if tt.expectedFound { - if provider == nil { - t.Errorf("Expected to find provider, but got nil") - return - } - - if provider.ID != tt.expectedProviderID { - t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID) - } - } else { - if provider != nil { - t.Errorf("Expected not to find provider, but got %v", provider) - } - } - }) - } -} - -// TestModelSelectionPriority tests the priority order for model selection -func TestModelSelectionPriority(t *testing.T) { - providers := []opencode.Provider{ - { - ID: "anthropic", - Models: map[string]opencode.Model{ - "claude-opus": {ID: "claude-opus"}, - }, - }, - { - ID: "openai", - Models: map[string]opencode.Model{ - "gpt-4": {ID: "gpt-4"}, - }, - }, - } - - tests := []struct { - name string - agentProviderID string - agentModelID string - configModel string - expectedProviderID string - expectedModelID string - description string - }{ - { - name: "agent model takes priority over config", - agentProviderID: "openai", - agentModelID: "gpt-4", - configModel: "anthropic/claude-opus", - expectedProviderID: "openai", - expectedModelID: "gpt-4", - description: "When agent specifies a model, it should be used even if config has a different model", - }, - { - name: "config model used when agent has no model", - agentProviderID: "", - agentModelID: "", - configModel: "anthropic/claude-opus", - expectedProviderID: "anthropic", - expectedModelID: "claude-opus", - description: "When agent has no model specified, config model should be used as fallback", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var selectedProvider *opencode.Provider - var selectedModel *opencode.Model - - // Simulate priority 2: Agent model check - if tt.agentModelID != "" { - selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID) - } - - // Simulate priority 3: Config model fallback - if selectedProvider == nil && tt.configModel != "" { - selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel) - } - - if selectedProvider == nil || selectedModel == nil { - t.Fatalf("Expected to find model, but got nil - %s", tt.description) - } - - if selectedProvider.ID != tt.expectedProviderID { - t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description) - } - - if selectedModel.ID != tt.expectedModelID { - t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description) - } - }) - } -} diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go deleted file mode 100644 index bd5086a45..000000000 --- a/packages/tui/internal/app/prompt.go +++ /dev/null @@ -1,283 +0,0 @@ -package app - -import ( - "errors" - "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()), - }, - } - - text := p.Text - textAttachments := []*attachment.Attachment{} - for _, attachment := range p.Attachments { - if attachment.Type == "text" { - textAttachments = append(textAttachments, attachment) - } - } - for i := 0; i < len(textAttachments)-1; i++ { - for j := i + 1; j < len(textAttachments); j++ { - if textAttachments[i].StartIndex < textAttachments[j].StartIndex { - textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i] - } - } - } - for _, att := range textAttachments { - if source, ok := att.GetTextSource(); ok { - if att.StartIndex > att.EndIndex || att.EndIndex > len(text) { - continue - } - text = text[:att.StartIndex] + source.Value + text[att.EndIndex:] - } - } - - parts := []opencode.PartUnion{opencode.TextPart{ - ID: id.Ascending(id.Part), - MessageID: messageID, - SessionID: sessionID, - Type: opencode.TextPartTypeText, - Text: text, - }} - for _, attachment := range p.Attachments { - if attachment.Type == "agent" { - source, _ := attachment.GetAgentSource() - parts = append(parts, opencode.AgentPart{ - ID: id.Ascending(id.Part), - MessageID: messageID, - SessionID: sessionID, - Name: source.Name, - Source: opencode.AgentPartSource{ - Value: attachment.Display, - Start: int64(attachment.StartIndex), - End: int64(attachment.EndIndex), - }, - }) - continue - } - - text := opencode.FilePartSourceText{ - Start: int64(attachment.StartIndex), - End: int64(attachment.EndIndex), - Value: attachment.Display, - } - source := &opencode.FilePartSource{} - switch attachment.Type { - case "text": - continue - case "file": - if fileSource, ok := attachment.GetFileSource(); ok { - source = &opencode.FilePartSource{ - Text: text, - Path: fileSource.Path, - Type: opencode.FilePartSourceTypeFile, - } - } - case "symbol": - if symbolSource, ok := attachment.GetSymbolSource(); ok { - 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) ToPrompt() (*Prompt, error) { - switch m.Info.(type) { - case opencode.UserMessage: - text := "" - attachments := []*attachment.Attachment{} - for _, part := range m.Parts { - switch p := part.(type) { - case opencode.TextPart: - if p.Synthetic { - continue - } - text += p.Text + " " - case opencode.AgentPart: - attachments = append(attachments, &attachment.Attachment{ - ID: p.ID, - Type: "agent", - Display: p.Source.Value, - StartIndex: int(p.Source.Start), - EndIndex: int(p.Source.End), - Source: &attachment.AgentSource{ - Name: p.Name, - }, - }) - case opencode.FilePart: - switch p.Source.Type { - case "file": - attachments = append(attachments, &attachment.Attachment{ - ID: p.ID, - Type: "file", - Display: p.Source.Text.Value, - URL: p.URL, - Filename: p.Filename, - MediaType: p.Mime, - StartIndex: int(p.Source.Text.Start), - EndIndex: int(p.Source.Text.End), - Source: &attachment.FileSource{ - Path: p.Source.Path, - Mime: p.Mime, - }, - }) - case "symbol": - r := p.Source.Range.(opencode.SymbolSourceRange) - attachments = append(attachments, &attachment.Attachment{ - ID: p.ID, - Type: "symbol", - Display: p.Source.Text.Value, - URL: p.URL, - Filename: p.Filename, - MediaType: p.Mime, - StartIndex: int(p.Source.Text.Start), - EndIndex: int(p.Source.Text.End), - Source: &attachment.SymbolSource{ - Path: p.Source.Path, - Name: p.Source.Name, - Kind: int(p.Source.Kind), - Range: attachment.SymbolRange{ - Start: attachment.Position{ - Line: int(r.Start.Line), - Char: int(r.Start.Character), - }, - End: attachment.Position{ - Line: int(r.End.Line), - Char: int(r.End.Character), - }, - }, - }, - }) - } - } - } - return &Prompt{ - Text: text, - Attachments: attachments, - }, nil - } - return nil, errors.New("unknown message type") -} - -func (m Message) ToSessionChatParams() []opencode.SessionPromptParamsPartUnion { - parts := []opencode.SessionPromptParamsPartUnion{} - 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), - }) - case opencode.AgentPart: - parts = append(parts, opencode.AgentPartInputParam{ - ID: opencode.F(p.ID), - Type: opencode.F(opencode.AgentPartInputTypeAgent), - Name: opencode.F(p.Name), - Source: opencode.F(opencode.AgentPartInputSourceParam{ - Value: opencode.F(p.Source.Value), - Start: opencode.F(p.Source.Start), - End: opencode.F(p.Source.End), - }), - }) - } - } - return parts -} diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go deleted file mode 100644 index cc65eea5e..000000000 --- a/packages/tui/internal/app/state.go +++ /dev/null @@ -1,174 +0,0 @@ -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 AgentUsage struct { - AgentName string `toml:"agent_name"` - LastUsed time.Time `toml:"last_used"` -} - -type AgentModel struct { - ProviderID string `toml:"provider_id"` - ModelID string `toml:"model_id"` -} - -type State struct { - Theme string `toml:"theme"` - AgentModel map[string]AgentModel `toml:"agent_model"` - Provider string `toml:"provider"` - Model string `toml:"model"` - Agent string `toml:"agent"` - RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` - RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"` - MessageHistory []Prompt `toml:"message_history"` - ShowToolDetails *bool `toml:"show_tool_details"` - ShowThinkingBlocks *bool `toml:"show_thinking_blocks"` -} - -func NewState() *State { - return &State{ - Theme: "opencode", - Agent: "build", - AgentModel: make(map[string]AgentModel), - RecentlyUsedModels: make([]ModelUsage, 0), - RecentlyUsedAgents: make([]AgentUsage, 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 - } - } -} - -// UpdateAgentUsage updates the recently used agents list with the specified agent -func (s *State) UpdateAgentUsage(agentName string) { - now := time.Now() - - // Check if this agent is already in the list - for i, usage := range s.RecentlyUsedAgents { - if usage.AgentName == agentName { - s.RecentlyUsedAgents[i].LastUsed = now - usage := s.RecentlyUsedAgents[i] - copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i]) - s.RecentlyUsedAgents[0] = usage - return - } - } - - newUsage := AgentUsage{ - AgentName: agentName, - LastUsed: now, - } - - // Prepend to slice and limit to last 20 entries - s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...) - if len(s.RecentlyUsedAgents) > 20 { - s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20] - } -} - -func (s *State) RemoveAgentFromRecentlyUsed(agentName string) { - for i, usage := range s.RecentlyUsedAgents { - if usage.AgentName == agentName { - s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[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) - } - - // Restore attachment sources types that were deserialized as map[string]any - for _, prompt := range state.MessageHistory { - for _, att := range prompt.Attachments { - att.RestoreSourceType() - } - } - - return &state, nil -} |
