app.go

  1package app
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"errors"
  7	"fmt"
  8	"log/slog"
  9	"maps"
 10	"sync"
 11	"time"
 12
 13	"github.com/charmbracelet/crush/internal/config"
 14	"github.com/charmbracelet/crush/internal/db"
 15	"github.com/charmbracelet/crush/internal/format"
 16	"github.com/charmbracelet/crush/internal/history"
 17	"github.com/charmbracelet/crush/internal/llm/agent"
 18
 19	"github.com/charmbracelet/crush/internal/lsp"
 20	"github.com/charmbracelet/crush/internal/message"
 21	"github.com/charmbracelet/crush/internal/permission"
 22	"github.com/charmbracelet/crush/internal/session"
 23)
 24
 25type App struct {
 26	Sessions    session.Service
 27	Messages    message.Service
 28	History     history.Service
 29	Permissions permission.Service
 30
 31	CoderAgent agent.Service
 32
 33	LSPClients map[string]*lsp.Client
 34
 35	clientsMutex sync.RWMutex
 36
 37	watcherCancelFuncs []context.CancelFunc
 38	cancelFuncsMutex   sync.Mutex
 39	watcherWG          sync.WaitGroup
 40
 41	config *config.Config
 42}
 43
 44func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 45	q := db.New(conn)
 46	sessions := session.NewService(q)
 47	messages := message.NewService(q)
 48	files := history.NewService(q, conn)
 49
 50	app := &App{
 51		Sessions:    sessions,
 52		Messages:    messages,
 53		History:     files,
 54		Permissions: permission.NewPermissionService(cfg.WorkingDir()),
 55		LSPClients:  make(map[string]*lsp.Client),
 56		config:      cfg,
 57	}
 58
 59	// Initialize LSP clients in the background
 60	go app.initLSPClients(ctx)
 61
 62	// TODO: remove the concept of agent config most likely
 63	coderAgentCfg := cfg.Agents["coder"]
 64	if coderAgentCfg.ID == "" {
 65		return nil, fmt.Errorf("coder agent configuration is missing")
 66	}
 67
 68	var err error
 69	app.CoderAgent, err = agent.NewAgent(
 70		coderAgentCfg,
 71		app.Permissions,
 72		app.Sessions,
 73		app.Messages,
 74		app.History,
 75		app.LSPClients,
 76	)
 77	if err != nil {
 78		slog.Error("Failed to create coder agent", "err", err)
 79		return nil, err
 80	}
 81
 82	return app, nil
 83}
 84
 85// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
 86func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
 87	slog.Info("Running in non-interactive mode")
 88
 89	// Start spinner if not in quiet mode
 90	var spinner *format.Spinner
 91	if !quiet {
 92		spinner = format.NewSpinner("Thinking...")
 93		spinner.Start()
 94		defer spinner.Stop()
 95	}
 96
 97	const maxPromptLengthForTitle = 100
 98	titlePrefix := "Non-interactive: "
 99	var titleSuffix string
100
101	if len(prompt) > maxPromptLengthForTitle {
102		titleSuffix = prompt[:maxPromptLengthForTitle] + "..."
103	} else {
104		titleSuffix = prompt
105	}
106	title := titlePrefix + titleSuffix
107
108	sess, err := a.Sessions.Create(ctx, title)
109	if err != nil {
110		return fmt.Errorf("failed to create session for non-interactive mode: %w", err)
111	}
112	slog.Info("Created session for non-interactive run", "session_id", sess.ID)
113
114	// Automatically approve all permission requests for this non-interactive session
115	a.Permissions.AutoApproveSession(sess.ID)
116
117	done, err := a.CoderAgent.Run(ctx, sess.ID, prompt)
118	if err != nil {
119		return fmt.Errorf("failed to start agent processing stream: %w", err)
120	}
121
122	result := <-done
123	if result.Error != nil {
124		if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
125			slog.Info("Agent processing cancelled", "session_id", sess.ID)
126			return nil
127		}
128		return fmt.Errorf("agent processing failed: %w", result.Error)
129	}
130
131	// Stop spinner before printing output
132	if !quiet && spinner != nil {
133		spinner.Stop()
134	}
135
136	// Get the text content from the response
137	content := "No content available"
138	if result.Message.Content().String() != "" {
139		content = result.Message.Content().String()
140	}
141
142	fmt.Println(format.FormatOutput(content, outputFormat))
143
144	slog.Info("Non-interactive run completed", "session_id", sess.ID)
145
146	return nil
147}
148
149// Shutdown performs a clean shutdown of the application
150func (app *App) Shutdown() {
151	// Cancel all watcher goroutines
152	app.cancelFuncsMutex.Lock()
153	for _, cancel := range app.watcherCancelFuncs {
154		cancel()
155	}
156	app.cancelFuncsMutex.Unlock()
157	app.watcherWG.Wait()
158
159	// Perform additional cleanup for LSP clients
160	app.clientsMutex.RLock()
161	clients := make(map[string]*lsp.Client, len(app.LSPClients))
162	maps.Copy(clients, app.LSPClients)
163	app.clientsMutex.RUnlock()
164
165	for name, client := range clients {
166		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
167		if err := client.Shutdown(shutdownCtx); err != nil {
168			slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
169		}
170		cancel()
171	}
172	app.CoderAgent.CancelAll()
173}
174
175func (app *App) UpdateAgentModel() error {
176	return app.CoderAgent.UpdateModel()
177}