mirror of
https://github.com/sst/opencode.git
synced 2025-08-24 06:54:09 +00:00
909 lines
22 KiB
Go
909 lines
22 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"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 {
|
|
Info opencode.App
|
|
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 SetEditorContentMsg struct {
|
|
Text string
|
|
}
|
|
type FileRenderedMsg struct {
|
|
FilePath string
|
|
}
|
|
type PermissionRespondedToMsg struct {
|
|
Response opencode.SessionPermissionRespondParamsResponse
|
|
}
|
|
|
|
func New(
|
|
ctx context.Context,
|
|
version string,
|
|
appInfo opencode.App,
|
|
agents []opencode.Agent,
|
|
httpClient *opencode.Client,
|
|
initialModel *string,
|
|
initialPrompt *string,
|
|
initialAgent *string,
|
|
initialSession *string,
|
|
) (*App, error) {
|
|
util.RootPath = appInfo.Path.Root
|
|
util.CwdPath = appInfo.Path.Cwd
|
|
|
|
configInfo, err := httpClient.Config.Get(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if configInfo.Keybinds.Leader == "" {
|
|
configInfo.Keybinds.Leader = "ctrl+x"
|
|
}
|
|
|
|
appStatePath := filepath.Join(appInfo.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(
|
|
appInfo.Path.Config,
|
|
appInfo.Path.Root,
|
|
appInfo.Path.Cwd,
|
|
); 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)
|
|
|
|
app := &App{
|
|
Info: appInfo,
|
|
Agents: agents,
|
|
Version: version,
|
|
StatePath: appStatePath,
|
|
Config: configInfo,
|
|
State: appState,
|
|
Client: httpClient,
|
|
AgentIndex: agentIndex,
|
|
Session: &opencode.Session{},
|
|
Messages: []Message{},
|
|
Commands: commands.LoadFromConfig(configInfo),
|
|
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]
|
|
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) 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
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: 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 3: 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 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
|
|
}
|
|
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) 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 {
|
|
_, 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.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
|
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) 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("Failed to submit shell command")()
|
|
}
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if response == nil {
|
|
return []opencode.Provider{}, nil
|
|
}
|
|
|
|
providers := *response
|
|
return providers.Providers, nil
|
|
}
|
|
|
|
// func (a *App) loadCustomKeybinds() {
|
|
//
|
|
// }
|