on main help sign in
chore: make agent optional
commit20431519f48af6548bd08327145f3c064ec7fc1d authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 8 Jul 2025, 13:15 (1 year ago) parentd5c6bbc
4 files changed+143−170
cmd/root.go+3−139 internal/app/app.go+137−28 internal/app/lsp.go+2−2 internal/tui/page/chat/chat.go+1−1
cmd/root.go+3−139
@@ -6,7 +6,6 @@ import (
66 "io"
77 "log/slog"
88 "os"
9− "sync"
109 "time"
1110
1211 tea "github.com/charmbracelet/bubbletea/v2"
@@ -16,7 +15,6 @@ import (
1615 "github.com/charmbracelet/crush/internal/format"
1716 "github.com/charmbracelet/crush/internal/llm/agent"
1817 "github.com/charmbracelet/crush/internal/log"
19− "github.com/charmbracelet/crush/internal/pubsub"
2018 "github.com/charmbracelet/crush/internal/tui"
2119 "github.com/charmbracelet/crush/internal/version"
2220 "github.com/charmbracelet/fang"
@@ -122,76 +120,17 @@ to assist developers in writing, debugging, and understanding code directly from
122120 tea.WithUniformKeyLayout(),
123121 )
124122
125− // Setup the subscriptions, this will send services events to the TUI
126− ch, cancelSubs := setupSubscriptions(app, ctx)
123+ go app.Subscribe(program)
127124
128− // Create a context for the TUI message handler
129− tuiCtx, tuiCancel := context.WithCancel(ctx)
130− var tuiWg sync.WaitGroup
131− tuiWg.Add(1)
132−
133− // Set up message handling for the TUI
134− go func() {
135− defer tuiWg.Done()
136− defer log.RecoverPanic("TUI-message-handler", func() {
137− attemptTUIRecovery(program)
138− })
139−
140− for {
141− select {
142− case <-tuiCtx.Done():
143− slog.Info("TUI message handler shutting down")
144− return
145− case msg, ok := <-ch:
146− if !ok {
147− slog.Info("TUI message channel closed")
148− return
149− }
150− program.Send(msg)
151− }
152− }
153− }()
154−
155− // Cleanup function for when the program exits
156− cleanup := func() {
157− // Shutdown the app
158− app.Shutdown()
159−
160− // Cancel subscriptions first
161− cancelSubs()
162−
163− // Then cancel TUI message handler
164− tuiCancel()
165−
166− // Wait for TUI message handler to finish
167− tuiWg.Wait()
168−
169− slog.Info("All goroutines cleaned up")
170− }
171−
172− // Run the TUI
173− result, err := program.Run()
174− cleanup()
175−
176− if err != nil {
125+ if _, err := program.Run(); err != nil {
177126 slog.Error(fmt.Sprintf("TUI run error: %v", err))
178127 return fmt.Errorf("TUI error: %v", err)
179128 }
180−
181− slog.Info(fmt.Sprintf("TUI exited with result: %v", result))
129+ app.Shutdown()
182130 return nil
183131 },
184132}
185133
186−// attemptTUIRecovery tries to recover the TUI after a panic
187−func attemptTUIRecovery(program *tea.Program) {
188− slog.Info("Attempting to recover TUI after panic")
189−
190− // We could try to restart the TUI or gracefully exit
191− // For now, we'll just quit the program to avoid further issues
192− program.Quit()
193−}
194−
195134func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
196135 go func() {
197136 defer log.RecoverPanic("MCP-goroutine", nil)
@@ -206,81 +145,6 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
206145 }()
207146}
208147
209−func setupSubscriber[T any](
210− ctx context.Context,
211− wg *sync.WaitGroup,
212− name string,
213− subscriber func(context.Context) <-chan pubsub.Event[T],
214− outputCh chan<- tea.Msg,
215−) {
216− wg.Add(1)
217− go func() {
218− defer wg.Done()
219− defer log.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
220−
221− subCh := subscriber(ctx)
222−
223− for {
224− select {
225− case event, ok := <-subCh:
226− if !ok {
227− slog.Info("subscription channel closed", "name", name)
228− return
229− }
230−
231− var msg tea.Msg = event
232−
233− select {
234− case outputCh <- msg:
235− case <-time.After(2 * time.Second):
236− slog.Warn("message dropped due to slow consumer", "name", name)
237− case <-ctx.Done():
238− slog.Info("subscription cancelled", "name", name)
239− return
240− }
241− case <-ctx.Done():
242− slog.Info("subscription cancelled", "name", name)
243− return
244− }
245− }
246− }()
247−}
248−
249−func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
250− ch := make(chan tea.Msg, 100)
251−
252− wg := sync.WaitGroup{}
253− ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
254−
255− setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
256− setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
257− setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
258− setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
259− setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch)
260−
261− cleanupFunc := func() {
262− slog.Info("Cancelling all subscriptions")
263− cancel() // Signal all goroutines to stop
264−
265− waitCh := make(chan struct{})
266− go func() {
267− defer log.RecoverPanic("subscription-cleanup", nil)
268− wg.Wait()
269− close(waitCh)
270− }()
271−
272− select {
273− case <-waitCh:
274− slog.Info("All subscription goroutines completed successfully")
275− close(ch) // Only close after all writers are confirmed done
276− case <-time.After(5 * time.Second):
277− slog.Warn("Timed out waiting for some subscription goroutines to complete")
278− close(ch)
279− }
280− }
281− return ch, cleanupFunc
282−}
283−
284148func Execute() {
285149 if err := fang.Execute(
286150 context.Background(),
internal/app/app.go+137−28
@@ -10,11 +10,13 @@ import (
1010 "sync"
1111 "time"
1212
13+ tea "github.com/charmbracelet/bubbletea/v2"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/db"
1516 "github.com/charmbracelet/crush/internal/format"
1617 "github.com/charmbracelet/crush/internal/history"
1718 "github.com/charmbracelet/crush/internal/llm/agent"
19+ "github.com/charmbracelet/crush/internal/pubsub"
1820
1921 "github.com/charmbracelet/crush/internal/lsp"
2022 "github.com/charmbracelet/crush/internal/message"
@@ -36,9 +38,18 @@ type App struct {
3638
3739 watcherCancelFuncs []context.CancelFunc
3840 cancelFuncsMutex sync.Mutex
39− watcherWG sync.WaitGroup
41+ lspWatcherWG sync.WaitGroup
4042
4143 config *config.Config
44+
45+ serviceEventsWG *sync.WaitGroup
46+ eventsCtx context.Context
47+ events chan tea.Msg
48+ tuiWG *sync.WaitGroup
49+
50+ // global context and cleanup functions
51+ globalCtx context.Context
52+ cleanupFuncs []func()
4253}
4354
4455func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
@@ -53,32 +64,29 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
5364 History: files,
5465 Permissions: permission.NewPermissionService(cfg.WorkingDir()),
5566 LSPClients: make(map[string]*lsp.Client),
56− config: cfg,
67+
68+ globalCtx: ctx,
69+
70+ config: cfg,
71+
72+ events: make(chan tea.Msg, 100),
73+ serviceEventsWG: &sync.WaitGroup{},
74+ tuiWG: &sync.WaitGroup{},
5775 }
5876
77+ app.setupEvents()
78+
5979 // Initialize LSP clients in the background
6080 go app.initLSPClients(ctx)
6181
6282 // 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
83+ if cfg.IsConfigured() {
84+ if err := app.InitCoderAgent(); err != nil {
85+ return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
86+ }
87+ } else {
88+ slog.Warn("No agent configuration found")
8089 }
81−
8290 return app, nil
8391}
8492
@@ -146,32 +154,133 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
146154 return nil
147155}
148156
157+func (app *App) UpdateAgentModel() error {
158+ return app.CoderAgent.UpdateModel()
159+}
160+
161+func (app *App) setupEvents() {
162+ ctx, cancel := context.WithCancel(app.globalCtx)
163+ app.eventsCtx = ctx
164+ setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
165+ setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
166+ setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
167+ setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
168+ cleanupFunc := func() {
169+ cancel()
170+ app.serviceEventsWG.Wait()
171+ }
172+ app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
173+}
174+
175+func setupSubscriber[T any](
176+ ctx context.Context,
177+ wg *sync.WaitGroup,
178+ name string,
179+ subscriber func(context.Context) <-chan pubsub.Event[T],
180+ outputCh chan<- tea.Msg,
181+) {
182+ wg.Add(1)
183+ go func() {
184+ defer wg.Done()
185+ subCh := subscriber(ctx)
186+ for {
187+ select {
188+ case event, ok := <-subCh:
189+ if !ok {
190+ slog.Debug("subscription channel closed", "name", name)
191+ return
192+ }
193+ var msg tea.Msg = event
194+ select {
195+ case outputCh <- msg:
196+ case <-time.After(2 * time.Second):
197+ slog.Warn("message dropped due to slow consumer", "name", name)
198+ case <-ctx.Done():
199+ slog.Debug("subscription cancelled", "name", name)
200+ return
201+ }
202+ case <-ctx.Done():
203+ slog.Debug("subscription cancelled", "name", name)
204+ return
205+ }
206+ }
207+ }()
208+}
209+
210+func (app *App) InitCoderAgent() error {
211+ coderAgentCfg := app.config.Agents["coder"]
212+ if coderAgentCfg.ID == "" {
213+ return fmt.Errorf("coder agent configuration is missing")
214+ }
215+ var err error
216+ app.CoderAgent, err = agent.NewAgent(
217+ coderAgentCfg,
218+ app.Permissions,
219+ app.Sessions,
220+ app.Messages,
221+ app.History,
222+ app.LSPClients,
223+ )
224+ if err != nil {
225+ slog.Error("Failed to create coder agent", "err", err)
226+ return err
227+ }
228+ setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events)
229+ return nil
230+}
231+
232+func (app *App) Subscribe(program *tea.Program) {
233+ app.tuiWG.Add(1)
234+ tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
235+ app.cleanupFuncs = append(app.cleanupFuncs, func() {
236+ slog.Debug("Cancelling TUI message handler")
237+ tuiCancel()
238+ app.tuiWG.Wait()
239+ })
240+ defer app.tuiWG.Done()
241+ for {
242+ select {
243+ case <-tuiCtx.Done():
244+ slog.Debug("TUI message handler shutting down")
245+ return
246+ case msg, ok := <-app.events:
247+ if !ok {
248+ slog.Debug("TUI message channel closed")
249+ return
250+ }
251+ program.Send(msg)
252+ }
253+ }
254+}
255+
149256// Shutdown performs a clean shutdown of the application
150257func (app *App) Shutdown() {
151− // Cancel all watcher goroutines
152258 app.cancelFuncsMutex.Lock()
153259 for _, cancel := range app.watcherCancelFuncs {
154260 cancel()
155261 }
156262 app.cancelFuncsMutex.Unlock()
157− app.watcherWG.Wait()
263+ app.lspWatcherWG.Wait()
158264
159− // Perform additional cleanup for LSP clients
160265 app.clientsMutex.RLock()
161266 clients := make(map[string]*lsp.Client, len(app.LSPClients))
162267 maps.Copy(clients, app.LSPClients)
163268 app.clientsMutex.RUnlock()
164269
165270 for name, client := range clients {
166− shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
271+ shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
167272 if err := client.Shutdown(shutdownCtx); err != nil {
168273 slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
169274 }
170275 cancel()
171276 }
172− app.CoderAgent.CancelAll()
173−}
277+ if app.CoderAgent != nil {
278+ app.CoderAgent.CancelAll()
279+ }
174280
175−func (app *App) UpdateAgentModel() error {
176− return app.CoderAgent.UpdateModel()
281+ for _, cleanup := range app.cleanupFuncs {
282+ if cleanup != nil {
283+ cleanup()
284+ }
285+ }
177286}
internal/app/lsp.go+2−2
@@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
7171 app.cancelFuncsMutex.Unlock()
7272
7373 // Add the watcher to a WaitGroup to track active goroutines
74− app.watcherWG.Add(1)
74+ app.lspWatcherWG.Add(1)
7575
7676 // Add to map with mutex protection before starting goroutine
7777 app.clientsMutex.Lock()
@@ -83,7 +83,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
8383
8484// runWorkspaceWatcher executes the workspace watcher for an LSP client
8585func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
86− defer app.watcherWG.Done()
86+ defer app.lspWatcherWG.Done()
8787 defer log.RecoverPanic("LSP-"+name, func() {
8888 // Try to restart the client
8989 app.restartLSPClient(ctx, name)
internal/tui/page/chat/chat.go+1−1
@@ -511,7 +511,7 @@ func (p *chatPage) Bindings() []key.Binding {
511511 p.keyMap.NewSession,
512512 p.keyMap.AddAttachment,
513513 }
514− if p.app.CoderAgent.IsBusy() {
514+ if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
515515 cancelBinding := p.keyMap.Cancel
516516 if p.canceling {
517517 cancelBinding = key.NewBinding(