1// Package app wires together services, coordinates agents, and manages
2// application lifecycle.
3package app
4
5import (
6 "context"
7 "database/sql"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "os"
13 "strings"
14 "sync"
15 "time"
16
17 tea "charm.land/bubbletea/v2"
18 "charm.land/fantasy"
19 "charm.land/lipgloss/v2"
20 "github.com/charmbracelet/catwalk/pkg/catwalk"
21 "github.com/charmbracelet/crush/internal/agent"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/csync"
25 "github.com/charmbracelet/crush/internal/db"
26 "github.com/charmbracelet/crush/internal/filetracker"
27 "github.com/charmbracelet/crush/internal/format"
28 "github.com/charmbracelet/crush/internal/history"
29 "github.com/charmbracelet/crush/internal/log"
30 "github.com/charmbracelet/crush/internal/lsp"
31 "github.com/charmbracelet/crush/internal/message"
32 "github.com/charmbracelet/crush/internal/permission"
33 "github.com/charmbracelet/crush/internal/pubsub"
34 "github.com/charmbracelet/crush/internal/session"
35 "github.com/charmbracelet/crush/internal/shell"
36 "github.com/charmbracelet/crush/internal/tui/components/anim"
37 "github.com/charmbracelet/crush/internal/tui/styles"
38 "github.com/charmbracelet/crush/internal/update"
39 "github.com/charmbracelet/crush/internal/version"
40 "github.com/charmbracelet/x/ansi"
41 "github.com/charmbracelet/x/exp/charmtone"
42 "github.com/charmbracelet/x/term"
43)
44
45// UpdateAvailableMsg is sent when a new version is available.
46type UpdateAvailableMsg struct {
47 CurrentVersion string
48 LatestVersion string
49 IsDevelopment bool
50}
51
52type App struct {
53 Sessions session.Service
54 Messages message.Service
55 History history.Service
56 Permissions permission.Service
57 FileTracker filetracker.Service
58
59 AgentCoordinator agent.Coordinator
60
61 LSPClients *csync.Map[string, *lsp.Client]
62
63 config *config.Config
64
65 serviceEventsWG *sync.WaitGroup
66 eventsCtx context.Context
67 events chan tea.Msg
68 tuiWG *sync.WaitGroup
69
70 // global context and cleanup functions
71 globalCtx context.Context
72 cleanupFuncs []func() error
73}
74
75// New initializes a new application instance.
76func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
77 q := db.New(conn)
78 sessions := session.NewService(q, conn)
79 messages := message.NewService(q)
80 files := history.NewService(q, conn)
81 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
82 var allowedTools []string
83 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
84 allowedTools = cfg.Permissions.AllowedTools
85 }
86
87 app := &App{
88 Sessions: sessions,
89 Messages: messages,
90 History: files,
91 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
92 FileTracker: filetracker.NewService(q),
93 LSPClients: csync.NewMap[string, *lsp.Client](),
94
95 globalCtx: ctx,
96
97 config: cfg,
98
99 events: make(chan tea.Msg, 100),
100 serviceEventsWG: &sync.WaitGroup{},
101 tuiWG: &sync.WaitGroup{},
102 }
103
104 app.setupEvents()
105
106 // Initialize LSP clients in the background.
107 go app.initLSPClients(ctx)
108
109 // Check for updates in the background.
110 go app.checkForUpdates(ctx)
111
112 go func() {
113 slog.Info("Initializing MCP clients")
114 mcp.Initialize(ctx, app.Permissions, cfg)
115 }()
116
117 // cleanup database upon app shutdown
118 app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
119
120 // TODO: remove the concept of agent config, most likely.
121 if !cfg.IsConfigured() {
122 slog.Warn("No agent configuration found")
123 return app, nil
124 }
125 if err := app.InitCoderAgent(ctx); err != nil {
126 return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
127 }
128 return app, nil
129}
130
131// Config returns the application configuration.
132func (app *App) Config() *config.Config {
133 return app.config
134}
135
136// RunNonInteractive runs the application in non-interactive mode with the
137// given prompt, printing to stdout.
138func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
139 slog.Info("Running in non-interactive mode")
140
141 ctx, cancel := context.WithCancel(ctx)
142 defer cancel()
143
144 if largeModel != "" || smallModel != "" {
145 if err := app.overrideModelsForNonInteractive(ctx, largeModel, smallModel); err != nil {
146 return fmt.Errorf("failed to override models: %w", err)
147 }
148 }
149
150 var (
151 spinner *format.Spinner
152 stdoutTTY bool
153 stderrTTY bool
154 stdinTTY bool
155 )
156
157 if f, ok := output.(*os.File); ok {
158 stdoutTTY = term.IsTerminal(f.Fd())
159 }
160 stderrTTY = term.IsTerminal(os.Stderr.Fd())
161 stdinTTY = term.IsTerminal(os.Stdin.Fd())
162
163 if !quiet && stderrTTY {
164 t := styles.CurrentTheme()
165
166 // Detect background color to set the appropriate color for the
167 // spinner's 'Generating...' text. Without this, that text would be
168 // unreadable in light terminals.
169 hasDarkBG := true
170 if f, ok := output.(*os.File); ok && stdinTTY && stdoutTTY {
171 hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
172 }
173 defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
174
175 spinner = format.NewSpinner(ctx, cancel, anim.Settings{
176 Size: 10,
177 Label: "Generating",
178 LabelColor: defaultFG,
179 GradColorA: t.Primary,
180 GradColorB: t.Secondary,
181 CycleColors: true,
182 })
183 spinner.Start()
184 }
185
186 // Helper function to stop spinner once.
187 stopSpinner := func() {
188 if !quiet && spinner != nil {
189 spinner.Stop()
190 spinner = nil
191 }
192 }
193
194 // Wait for MCP initialization to complete before reading MCP tools.
195 if err := mcp.WaitForInit(ctx); err != nil {
196 return fmt.Errorf("failed to wait for MCP initialization: %w", err)
197 }
198
199 // force update of agent models before running so mcp tools are loaded
200 app.AgentCoordinator.UpdateModels(ctx)
201
202 defer stopSpinner()
203
204 const maxPromptLengthForTitle = 100
205 const titlePrefix = "Non-interactive: "
206 var titleSuffix string
207
208 if len(prompt) > maxPromptLengthForTitle {
209 titleSuffix = prompt[:maxPromptLengthForTitle] + "..."
210 } else {
211 titleSuffix = prompt
212 }
213 title := titlePrefix + titleSuffix
214
215 sess, err := app.Sessions.Create(ctx, title)
216 if err != nil {
217 return fmt.Errorf("failed to create session for non-interactive mode: %w", err)
218 }
219 slog.Info("Created session for non-interactive run", "session_id", sess.ID)
220
221 // Automatically approve all permission requests for this non-interactive
222 // session.
223 app.Permissions.AutoApproveSession(sess.ID)
224
225 type response struct {
226 result *fantasy.AgentResult
227 err error
228 }
229 done := make(chan response, 1)
230
231 go func(ctx context.Context, sessionID, prompt string) {
232 result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
233 if err != nil {
234 done <- response{
235 err: fmt.Errorf("failed to start agent processing stream: %w", err),
236 }
237 }
238 done <- response{
239 result: result,
240 }
241 }(ctx, sess.ID, prompt)
242
243 messageEvents := app.Messages.Subscribe(ctx)
244 messageReadBytes := make(map[string]int)
245
246 defer func() {
247 if stderrTTY {
248 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
249 }
250
251 // Always print a newline at the end. If output is a TTY this will
252 // prevent the prompt from overwriting the last line of output.
253 _, _ = fmt.Fprintln(output)
254 }()
255
256 for {
257 if stderrTTY {
258 // HACK: Reinitialize the terminal progress bar on every iteration
259 // so it doesn't get hidden by the terminal due to inactivity.
260 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
261 }
262
263 select {
264 case result := <-done:
265 stopSpinner()
266 if result.err != nil {
267 if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
268 slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
269 return nil
270 }
271 return fmt.Errorf("agent processing failed: %w", result.err)
272 }
273 return nil
274
275 case event := <-messageEvents:
276 msg := event.Payload
277 if msg.SessionID == sess.ID && msg.Role == message.Assistant && len(msg.Parts) > 0 {
278 stopSpinner()
279
280 content := msg.Content().String()
281 readBytes := messageReadBytes[msg.ID]
282
283 if len(content) < readBytes {
284 slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(content), "read_bytes", readBytes)
285 return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
286 }
287
288 part := content[readBytes:]
289 // Trim leading whitespace. Sometimes the LLM includes leading
290 // formatting and intentation, which we don't want here.
291 if readBytes == 0 {
292 part = strings.TrimLeft(part, " \t")
293 }
294 fmt.Fprint(output, part)
295 messageReadBytes[msg.ID] = len(content)
296 }
297
298 case <-ctx.Done():
299 stopSpinner()
300 return ctx.Err()
301 }
302 }
303}
304
305func (app *App) UpdateAgentModel(ctx context.Context) error {
306 if app.AgentCoordinator == nil {
307 return fmt.Errorf("agent configuration is missing")
308 }
309 return app.AgentCoordinator.UpdateModels(ctx)
310}
311
312// overrideModelsForNonInteractive parses the model strings and temporarily
313// overrides the model configurations, then rebuilds the agent.
314// Format: "model-name" (searches all providers) or "provider/model-name".
315// Model matching is case-insensitive.
316// If largeModel is provided but smallModel is not, the small model defaults to
317// the provider's default small model.
318func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
319 providers := app.config.Providers.Copy()
320
321 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
322 if err != nil {
323 return err
324 }
325
326 var largeProviderID string
327
328 // Override large model.
329 if largeModel != "" {
330 found, err := validateMatches(largeMatches, largeModel, "large")
331 if err != nil {
332 return err
333 }
334 largeProviderID = found.provider
335 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
336 app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{
337 Provider: found.provider,
338 Model: found.modelID,
339 }
340 }
341
342 // Override small model.
343 switch {
344 case smallModel != "":
345 found, err := validateMatches(smallMatches, smallModel, "small")
346 if err != nil {
347 return err
348 }
349 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
350 app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{
351 Provider: found.provider,
352 Model: found.modelID,
353 }
354
355 case largeModel != "":
356 // No small model specified, but large model was - use provider's default.
357 smallCfg := app.GetDefaultSmallModel(largeProviderID)
358 app.config.Models[config.SelectedModelTypeSmall] = smallCfg
359 }
360
361 return app.AgentCoordinator.UpdateModels(ctx)
362}
363
364// GetDefaultSmallModel returns the default small model for the given
365// provider. Falls back to the large model if no default is found.
366func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
367 cfg := app.config
368 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
369
370 // Find the provider in the known providers list to get its default small model.
371 knownProviders, _ := config.Providers(cfg)
372 var knownProvider *catwalk.Provider
373 for _, p := range knownProviders {
374 if string(p.ID) == providerID {
375 knownProvider = &p
376 break
377 }
378 }
379
380 // For unknown/local providers, use the large model as small.
381 if knownProvider == nil {
382 slog.Warn("Using large model as small model for unknown provider", "provider", providerID, "model", largeModelCfg.Model)
383 return largeModelCfg
384 }
385
386 defaultSmallModelID := knownProvider.DefaultSmallModelID
387 model := cfg.GetModel(providerID, defaultSmallModelID)
388 if model == nil {
389 slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model)
390 return largeModelCfg
391 }
392
393 slog.Info("Using provider default small model", "provider", providerID, "model", defaultSmallModelID)
394 return config.SelectedModel{
395 Provider: providerID,
396 Model: defaultSmallModelID,
397 MaxTokens: model.DefaultMaxTokens,
398 ReasoningEffort: model.DefaultReasoningEffort,
399 }
400}
401
402func (app *App) setupEvents() {
403 ctx, cancel := context.WithCancel(app.globalCtx)
404 app.eventsCtx = ctx
405 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
406 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
407 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
408 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
409 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
410 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
411 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
412 cleanupFunc := func() error {
413 cancel()
414 app.serviceEventsWG.Wait()
415 return nil
416 }
417 app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
418}
419
420func setupSubscriber[T any](
421 ctx context.Context,
422 wg *sync.WaitGroup,
423 name string,
424 subscriber func(context.Context) <-chan pubsub.Event[T],
425 outputCh chan<- tea.Msg,
426) {
427 wg.Go(func() {
428 subCh := subscriber(ctx)
429 for {
430 select {
431 case event, ok := <-subCh:
432 if !ok {
433 slog.Debug("subscription channel closed", "name", name)
434 return
435 }
436 var msg tea.Msg = event
437 select {
438 case outputCh <- msg:
439 case <-time.After(2 * time.Second):
440 slog.Warn("message dropped due to slow consumer", "name", name)
441 case <-ctx.Done():
442 slog.Debug("subscription cancelled", "name", name)
443 return
444 }
445 case <-ctx.Done():
446 slog.Debug("subscription cancelled", "name", name)
447 return
448 }
449 }
450 })
451}
452
453func (app *App) InitCoderAgent(ctx context.Context) error {
454 coderAgentCfg := app.config.Agents[config.AgentCoder]
455 if coderAgentCfg.ID == "" {
456 return fmt.Errorf("coder agent configuration is missing")
457 }
458 var err error
459 app.AgentCoordinator, err = agent.NewCoordinator(
460 ctx,
461 app.config,
462 app.Sessions,
463 app.Messages,
464 app.Permissions,
465 app.History,
466 app.FileTracker,
467 app.LSPClients,
468 )
469 if err != nil {
470 slog.Error("Failed to create coder agent", "err", err)
471 return err
472 }
473 return nil
474}
475
476// Subscribe sends events to the TUI as tea.Msgs.
477func (app *App) Subscribe(program *tea.Program) {
478 defer log.RecoverPanic("app.Subscribe", func() {
479 slog.Info("TUI subscription panic: attempting graceful shutdown")
480 program.Quit()
481 })
482
483 app.tuiWG.Add(1)
484 tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
485 app.cleanupFuncs = append(app.cleanupFuncs, func() error {
486 slog.Debug("Cancelling TUI message handler")
487 tuiCancel()
488 app.tuiWG.Wait()
489 return nil
490 })
491 defer app.tuiWG.Done()
492
493 for {
494 select {
495 case <-tuiCtx.Done():
496 slog.Debug("TUI message handler shutting down")
497 return
498 case msg, ok := <-app.events:
499 if !ok {
500 slog.Debug("TUI message channel closed")
501 return
502 }
503 program.Send(msg)
504 }
505 }
506}
507
508// Shutdown performs a graceful shutdown of the application.
509func (app *App) Shutdown() {
510 start := time.Now()
511 defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
512
513 // First, cancel all agents and wait for them to finish. This must complete
514 // before closing the DB so agents can finish writing their state.
515 if app.AgentCoordinator != nil {
516 app.AgentCoordinator.CancelAll()
517 }
518
519 // Now run remaining cleanup tasks in parallel.
520 var wg sync.WaitGroup
521
522 // Kill all background shells.
523 wg.Go(func() {
524 shell.GetBackgroundShellManager().KillAll()
525 })
526
527 // Shutdown all LSP clients.
528 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
529 defer cancel()
530 for name, client := range app.LSPClients.Seq2() {
531 wg.Go(func() {
532 if err := client.Close(shutdownCtx); err != nil &&
533 !errors.Is(err, io.EOF) &&
534 !errors.Is(err, context.Canceled) &&
535 err.Error() != "signal: killed" {
536 slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
537 }
538 })
539 }
540
541 // Call all cleanup functions.
542 for _, cleanup := range app.cleanupFuncs {
543 if cleanup != nil {
544 wg.Go(func() {
545 if err := cleanup(); err != nil {
546 slog.Error("Failed to cleanup app properly on shutdown", "error", err)
547 }
548 })
549 }
550 }
551 wg.Wait()
552}
553
554// checkForUpdates checks for available updates.
555func (app *App) checkForUpdates(ctx context.Context) {
556 checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
557 defer cancel()
558
559 info, err := update.Check(checkCtx, version.Version, update.Default)
560 if err != nil || !info.Available() {
561 return
562 }
563 app.events <- UpdateAvailableMsg{
564 CurrentVersion: info.Current,
565 LatestVersion: info.Latest,
566 IsDevelopment: info.IsDevelopment(),
567 }
568}