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 "sync"
14 "time"
15
16 "charm.land/fantasy"
17 tea "github.com/charmbracelet/bubbletea/v2"
18 "github.com/charmbracelet/crush/internal/agent"
19 "github.com/charmbracelet/crush/internal/agent/tools"
20 "github.com/charmbracelet/crush/internal/config"
21 "github.com/charmbracelet/crush/internal/csync"
22 "github.com/charmbracelet/crush/internal/db"
23 "github.com/charmbracelet/crush/internal/format"
24 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/log"
26 "github.com/charmbracelet/crush/internal/lsp"
27 "github.com/charmbracelet/crush/internal/message"
28 "github.com/charmbracelet/crush/internal/permission"
29 "github.com/charmbracelet/crush/internal/pubsub"
30 "github.com/charmbracelet/crush/internal/session"
31 "github.com/charmbracelet/crush/internal/tui/components/anim"
32 "github.com/charmbracelet/crush/internal/tui/styles"
33 "github.com/charmbracelet/lipgloss/v2"
34 "github.com/charmbracelet/x/ansi"
35 "github.com/charmbracelet/x/exp/charmtone"
36)
37
38type App struct {
39 Sessions session.Service
40 Messages message.Service
41 History history.Service
42 Permissions permission.Service
43
44 AgentCoordinator agent.Coordinator
45
46 LSPClients *csync.Map[string, *lsp.Client]
47
48 config *config.Config
49
50 serviceEventsWG *sync.WaitGroup
51 eventsCtx context.Context
52 events chan tea.Msg
53 tuiWG *sync.WaitGroup
54
55 // global context and cleanup functions
56 globalCtx context.Context
57 cleanupFuncs []func() error
58}
59
60// New initializes a new applcation instance.
61func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
62 q := db.New(conn)
63 sessions := session.NewService(q)
64 messages := message.NewService(q)
65 files := history.NewService(q, conn)
66 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
67 allowedTools := []string{}
68 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
69 allowedTools = cfg.Permissions.AllowedTools
70 }
71
72 app := &App{
73 Sessions: sessions,
74 Messages: messages,
75 History: files,
76 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
77 LSPClients: csync.NewMap[string, *lsp.Client](),
78
79 globalCtx: ctx,
80
81 config: cfg,
82
83 events: make(chan tea.Msg, 100),
84 serviceEventsWG: &sync.WaitGroup{},
85 tuiWG: &sync.WaitGroup{},
86 }
87
88 app.setupEvents()
89
90 // Initialize LSP clients in the background.
91 app.initLSPClients(ctx)
92
93 // cleanup database upon app shutdown
94 app.cleanupFuncs = append(app.cleanupFuncs, conn.Close)
95
96 // TODO: remove the concept of agent config, most likely.
97 if !cfg.IsConfigured() {
98 slog.Warn("No agent configuration found")
99 return app, nil
100 }
101 if err := app.InitCoderAgent(ctx); err != nil {
102 return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
103 }
104 return app, nil
105}
106
107// Config returns the application configuration.
108func (app *App) Config() *config.Config {
109 return app.config
110}
111
112// RunNonInteractive runs the application in non-interactive mode with the
113// given prompt, printing to stdout.
114func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt string, quiet bool) error {
115 slog.Info("Running in non-interactive mode")
116
117 ctx, cancel := context.WithCancel(ctx)
118 defer cancel()
119
120 var spinner *format.Spinner
121 if !quiet {
122 t := styles.CurrentTheme()
123
124 // Detect background color to set the appropriate color for the
125 // spinner's 'Generating...' text. Without this, that text would be
126 // unreadable in light terminals.
127 hasDarkBG := true
128 if f, ok := output.(*os.File); ok {
129 hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
130 }
131 defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
132
133 spinner = format.NewSpinner(ctx, cancel, anim.Settings{
134 Size: 10,
135 Label: "Generating",
136 LabelColor: defaultFG,
137 GradColorA: t.Primary,
138 GradColorB: t.Secondary,
139 CycleColors: true,
140 })
141 spinner.Start()
142 }
143
144 // Helper function to stop spinner once.
145 stopSpinner := func() {
146 if !quiet && spinner != nil {
147 spinner.Stop()
148 spinner = nil
149 }
150 }
151 defer stopSpinner()
152
153 const maxPromptLengthForTitle = 100
154 const titlePrefix = "Non-interactive: "
155 var titleSuffix string
156
157 if len(prompt) > maxPromptLengthForTitle {
158 titleSuffix = prompt[:maxPromptLengthForTitle] + "..."
159 } else {
160 titleSuffix = prompt
161 }
162 title := titlePrefix + titleSuffix
163
164 sess, err := app.Sessions.Create(ctx, title)
165 if err != nil {
166 return fmt.Errorf("failed to create session for non-interactive mode: %w", err)
167 }
168 slog.Info("Created session for non-interactive run", "session_id", sess.ID)
169
170 // Automatically approve all permission requests for this non-interactive
171 // session.
172 app.Permissions.AutoApproveSession(sess.ID)
173
174 type response struct {
175 result *fantasy.AgentResult
176 err error
177 }
178 done := make(chan response, 1)
179
180 go func(ctx context.Context, sessionID, prompt string) {
181 result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
182 if err != nil {
183 done <- response{
184 err: fmt.Errorf("failed to start agent processing stream: %w", err),
185 }
186 }
187 done <- response{
188 result: result,
189 }
190 }(ctx, sess.ID, prompt)
191
192 messageEvents := app.Messages.Subscribe(ctx)
193 messageReadBytes := make(map[string]int)
194
195 defer func() {
196 _, _ = fmt.Printf(ansi.ResetProgressBar)
197
198 // Always print a newline at the end. If output is a TTY this will
199 // prevent the prompt from overwriting the last line of output.
200 _, _ = fmt.Fprintln(output)
201 }()
202
203 for {
204 // HACK: Reinitialize the terminal progress bar on every iteration so
205 // it doesn't get hidden by the terminal due to inactivity.
206 _, _ = fmt.Printf(ansi.SetIndeterminateProgressBar)
207
208 select {
209 case result := <-done:
210 stopSpinner()
211 if result.err != nil {
212 if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
213 slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
214 return nil
215 }
216 return fmt.Errorf("agent processing failed: %w", result.err)
217 }
218 return nil
219
220 case event := <-messageEvents:
221 msg := event.Payload
222 if msg.SessionID == sess.ID && msg.Role == message.Assistant && len(msg.Parts) > 0 {
223 stopSpinner()
224
225 content := msg.Content().String()
226 readBytes := messageReadBytes[msg.ID]
227
228 if len(content) < readBytes {
229 slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(content), "read_bytes", readBytes)
230 return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
231 }
232
233 part := content[readBytes:]
234 fmt.Fprint(output, part)
235 messageReadBytes[msg.ID] = len(content)
236 }
237
238 case <-ctx.Done():
239 stopSpinner()
240 return ctx.Err()
241 }
242 }
243}
244
245func (app *App) UpdateAgentModel(ctx context.Context) error {
246 return app.AgentCoordinator.UpdateModels(ctx)
247}
248
249func (app *App) setupEvents() {
250 ctx, cancel := context.WithCancel(app.globalCtx)
251 app.eventsCtx = ctx
252 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
253 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
254 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
255 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
256 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
257 setupSubscriber(ctx, app.serviceEventsWG, "mcp", tools.SubscribeMCPEvents, app.events)
258 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
259 cleanupFunc := func() error {
260 cancel()
261 app.serviceEventsWG.Wait()
262 return nil
263 }
264 app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
265}
266
267func setupSubscriber[T any](
268 ctx context.Context,
269 wg *sync.WaitGroup,
270 name string,
271 subscriber func(context.Context) <-chan pubsub.Event[T],
272 outputCh chan<- tea.Msg,
273) {
274 wg.Go(func() {
275 subCh := subscriber(ctx)
276 for {
277 select {
278 case event, ok := <-subCh:
279 if !ok {
280 slog.Debug("subscription channel closed", "name", name)
281 return
282 }
283 var msg tea.Msg = event
284 select {
285 case outputCh <- msg:
286 case <-time.After(2 * time.Second):
287 slog.Warn("message dropped due to slow consumer", "name", name)
288 case <-ctx.Done():
289 slog.Debug("subscription cancelled", "name", name)
290 return
291 }
292 case <-ctx.Done():
293 slog.Debug("subscription cancelled", "name", name)
294 return
295 }
296 }
297 })
298}
299
300func (app *App) InitCoderAgent(ctx context.Context) error {
301 coderAgentCfg := app.config.Agents[config.AgentCoder]
302 if coderAgentCfg.ID == "" {
303 return fmt.Errorf("coder agent configuration is missing")
304 }
305 var err error
306 app.AgentCoordinator, err = agent.NewCoordinator(
307 ctx,
308 app.config,
309 app.Sessions,
310 app.Messages,
311 app.Permissions,
312 app.History,
313 app.LSPClients,
314 )
315 if err != nil {
316 slog.Error("Failed to create coder agent", "err", err)
317 return err
318 }
319
320 // Add MCP client cleanup to shutdown process
321 app.cleanupFuncs = append(app.cleanupFuncs, tools.CloseMCPClients)
322 return nil
323}
324
325// Subscribe sends events to the TUI as tea.Msgs.
326func (app *App) Subscribe(program *tea.Program) {
327 defer log.RecoverPanic("app.Subscribe", func() {
328 slog.Info("TUI subscription panic: attempting graceful shutdown")
329 program.Quit()
330 })
331
332 app.tuiWG.Add(1)
333 tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
334 app.cleanupFuncs = append(app.cleanupFuncs, func() error {
335 slog.Debug("Cancelling TUI message handler")
336 tuiCancel()
337 app.tuiWG.Wait()
338 return nil
339 })
340 defer app.tuiWG.Done()
341
342 for {
343 select {
344 case <-tuiCtx.Done():
345 slog.Debug("TUI message handler shutting down")
346 return
347 case msg, ok := <-app.events:
348 if !ok {
349 slog.Debug("TUI message channel closed")
350 return
351 }
352 program.Send(msg)
353 }
354 }
355}
356
357// Shutdown performs a graceful shutdown of the application.
358func (app *App) Shutdown() {
359 if app.AgentCoordinator != nil {
360 app.AgentCoordinator.CancelAll()
361 }
362
363 // Shutdown all LSP clients.
364 for name, client := range app.LSPClients.Seq2() {
365 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
366 if err := client.Close(shutdownCtx); err != nil {
367 slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
368 }
369 cancel()
370 }
371
372 // Call call cleanup functions.
373 for _, cleanup := range app.cleanupFuncs {
374 if cleanup != nil {
375 if err := cleanup(); err != nil {
376 slog.Error("Failed to cleanup app properly on shutdown", "error", err)
377 }
378 }
379 }
380}