1package tui
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "math/rand"
8 "slices"
9 "strings"
10 "time"
11
12 "charm.land/bubbles/v2/key"
13 tea "charm.land/bubbletea/v2"
14 "charm.land/lipgloss/v2"
15 "github.com/charmbracelet/crush/internal/agent"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/app"
18 "github.com/charmbracelet/crush/internal/config"
19 "github.com/charmbracelet/crush/internal/event"
20 "github.com/charmbracelet/crush/internal/permission"
21 "github.com/charmbracelet/crush/internal/pubsub"
22 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
24 "github.com/charmbracelet/crush/internal/tui/components/completions"
25 "github.com/charmbracelet/crush/internal/tui/components/core"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
27 "github.com/charmbracelet/crush/internal/tui/components/core/status"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
35 "github.com/charmbracelet/crush/internal/tui/page"
36 "github.com/charmbracelet/crush/internal/tui/page/chat"
37 "github.com/charmbracelet/crush/internal/tui/styles"
38 "github.com/charmbracelet/crush/internal/tui/util"
39 "golang.org/x/text/cases"
40 "golang.org/x/text/language"
41)
42
43var lastMouseEvent time.Time
44
45func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
46 switch msg.(type) {
47 case tea.MouseWheelMsg, tea.MouseMotionMsg:
48 now := time.Now()
49 // trackpad is sending too many requests
50 if now.Sub(lastMouseEvent) < 15*time.Millisecond {
51 return nil
52 }
53 lastMouseEvent = now
54 }
55 return msg
56}
57
58// appModel represents the main application model that manages pages, dialogs, and UI state.
59type appModel struct {
60 wWidth, wHeight int // Window dimensions
61 width, height int
62 keyMap KeyMap
63
64 currentPage page.PageID
65 previousPage page.PageID
66 pages map[page.PageID]util.Model
67 loadedPages map[page.PageID]bool
68
69 // Status
70 status status.StatusCmp
71 showingFullHelp bool
72
73 app *app.App
74
75 dialog dialogs.DialogCmp
76 completions completions.Completions
77 isConfigured bool
78
79 // Chat Page Specific
80 selectedSessionID string // The ID of the currently selected session
81
82 // sendProgressBar instructs the TUI to send progress bar updates to the
83 // terminal.
84 sendProgressBar bool
85
86 // QueryVersion instructs the TUI to query for the terminal version when it
87 // starts.
88 QueryVersion bool
89}
90
91// Init initializes the application model and returns initial commands.
92func (a appModel) Init() tea.Cmd {
93 item, ok := a.pages[a.currentPage]
94 if !ok {
95 return nil
96 }
97
98 var cmds []tea.Cmd
99 cmd := item.Init()
100 cmds = append(cmds, cmd)
101 a.loadedPages[a.currentPage] = true
102
103 cmd = a.status.Init()
104 cmds = append(cmds, cmd)
105 if a.QueryVersion {
106 cmds = append(cmds, tea.RequestTerminalVersion)
107 }
108
109 return tea.Batch(cmds...)
110}
111
112// Update handles incoming messages and updates the application state.
113func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114 var cmds []tea.Cmd
115 var cmd tea.Cmd
116 a.isConfigured = config.HasInitialDataConfig()
117
118 switch msg := msg.(type) {
119 case tea.EnvMsg:
120 // Is this Windows Terminal?
121 if !a.sendProgressBar {
122 a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
123 }
124 case tea.TerminalVersionMsg:
125 termVersion := strings.ToLower(msg.Name)
126 // Only enable progress bar for the following terminals.
127 if !a.sendProgressBar {
128 a.sendProgressBar = strings.Contains(termVersion, "ghostty")
129 }
130 return a, nil
131 case tea.KeyboardEnhancementsMsg:
132 // A non-zero value means we have key disambiguation support.
133 if msg.Flags > 0 {
134 a.keyMap.Models.SetHelp("ctrl+m", "models")
135 }
136 for id, page := range a.pages {
137 m, pageCmd := page.Update(msg)
138 a.pages[id] = m
139
140 if pageCmd != nil {
141 cmds = append(cmds, pageCmd)
142 }
143 }
144 return a, tea.Batch(cmds...)
145 case tea.WindowSizeMsg:
146 a.wWidth, a.wHeight = msg.Width, msg.Height
147 a.completions.Update(msg)
148 return a, a.handleWindowResize(msg.Width, msg.Height)
149
150 case pubsub.Event[mcp.Event]:
151 switch msg.Payload.Type {
152 case mcp.EventStateChanged:
153 return a, a.handleStateChanged(context.Background())
154 case mcp.EventPromptsListChanged:
155 return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
156 case mcp.EventToolsListChanged:
157 return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name, a.app.AgentCoordinator)
158 }
159
160 // Completions messages
161 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
162 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
163 u, completionCmd := a.completions.Update(msg)
164 if model, ok := u.(completions.Completions); ok {
165 a.completions = model
166 }
167
168 return a, completionCmd
169
170 // Dialog messages
171 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
172 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
173 a.completions = u.(completions.Completions)
174 u, dialogCmd := a.dialog.Update(msg)
175 a.dialog = u.(dialogs.DialogCmp)
176 return a, tea.Batch(completionCmd, dialogCmd)
177 case commands.ShowArgumentsDialogMsg:
178 var args []commands.Argument
179 for _, arg := range msg.ArgNames {
180 args = append(args, commands.Argument{
181 Name: arg,
182 Title: cases.Title(language.English).String(arg),
183 Required: true,
184 })
185 }
186 return a, util.CmdHandler(
187 dialogs.OpenDialogMsg{
188 Model: commands.NewCommandArgumentsDialog(
189 msg.CommandID,
190 msg.CommandID,
191 msg.CommandID,
192 msg.Description,
193 args,
194 msg.OnSubmit,
195 ),
196 },
197 )
198 case commands.ShowMCPPromptArgumentsDialogMsg:
199 args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
200 for _, arg := range msg.Prompt.Arguments {
201 args = append(args, commands.Argument(*arg))
202 }
203 dialog := commands.NewCommandArgumentsDialog(
204 msg.Prompt.Name,
205 msg.Prompt.Title,
206 msg.Prompt.Name,
207 msg.Prompt.Description,
208 args,
209 msg.OnSubmit,
210 )
211 return a, util.CmdHandler(
212 dialogs.OpenDialogMsg{
213 Model: dialog,
214 },
215 )
216 // Page change messages
217 case page.PageChangeMsg:
218 return a, a.moveToPage(msg.ID)
219
220 // Status Messages
221 case util.InfoMsg, util.ClearStatusMsg:
222 s, statusCmd := a.status.Update(msg)
223 a.status = s.(status.StatusCmp)
224 cmds = append(cmds, statusCmd)
225 return a, tea.Batch(cmds...)
226
227 // Session
228 case cmpChat.SessionSelectedMsg:
229 a.selectedSessionID = msg.ID
230 case cmpChat.SessionClearedMsg:
231 a.selectedSessionID = ""
232 // Commands
233 case commands.SwitchSessionsMsg:
234 return a, func() tea.Msg {
235 allSessions, _ := a.app.Sessions.List(context.Background())
236 return dialogs.OpenDialogMsg{
237 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
238 }
239 }
240
241 case commands.SwitchModelMsg:
242 return a, util.CmdHandler(
243 dialogs.OpenDialogMsg{
244 Model: models.NewModelDialogCmp(),
245 },
246 )
247 // Compact
248 case commands.CompactMsg:
249 return a, func() tea.Msg {
250 err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
251 if err != nil {
252 return util.ReportError(err)()
253 }
254 return nil
255 }
256 case commands.QuitMsg:
257 return a, util.CmdHandler(dialogs.OpenDialogMsg{
258 Model: quit.NewQuitDialog(),
259 })
260 case commands.EnableDockerMCPMsg:
261 return a, func() tea.Msg {
262 cfg := config.Get()
263 if err := cfg.EnableDockerMCP(); err != nil {
264 return util.ReportError(err)()
265 }
266
267 // Initialize the Docker MCP client immediately.
268 ctx := context.Background()
269 if err := mcp.InitializeSingle(ctx, config.DockerMCPName, cfg); err != nil {
270 return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
271 }
272
273 return util.ReportInfo("Docker MCP enabled and started successfully")()
274 }
275 case commands.DisableDockerMCPMsg:
276 return a, func() tea.Msg {
277 // Close the Docker MCP client.
278 if err := mcp.DisableSingle(config.DockerMCPName); err != nil {
279 return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
280 }
281
282 // Remove from config and persist.
283 cfg := config.Get()
284 if err := cfg.DisableDockerMCP(); err != nil {
285 return util.ReportError(err)()
286 }
287
288 return util.ReportInfo("Docker MCP disabled successfully")()
289 }
290 case commands.ToggleYoloModeMsg:
291 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
292 case commands.ToggleHelpMsg:
293 a.status.ToggleFullHelp()
294 a.showingFullHelp = !a.showingFullHelp
295 return a, a.handleWindowResize(a.wWidth, a.wHeight)
296 // Model Switch
297 case models.ModelSelectedMsg:
298 if a.app.AgentCoordinator.IsBusy() {
299 return a, util.ReportWarn("Agent is busy, please wait...")
300 }
301
302 cfg := config.Get()
303 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
304 return a, util.ReportError(err)
305 }
306
307 go a.app.UpdateAgentModel(context.TODO())
308
309 modelTypeName := "large"
310 if msg.ModelType == config.SelectedModelTypeSmall {
311 modelTypeName = "small"
312 }
313 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
314
315 // File Picker
316 case commands.OpenFilePickerMsg:
317 event.FilePickerOpened()
318
319 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
320 // If the commands dialog is already open, close it
321 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
322 }
323 return a, util.CmdHandler(dialogs.OpenDialogMsg{
324 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
325 })
326 // Permissions
327 case pubsub.Event[permission.PermissionNotification]:
328 item, ok := a.pages[a.currentPage]
329 if !ok {
330 return a, nil
331 }
332
333 // Forward to view.
334 updated, itemCmd := item.Update(msg)
335 a.pages[a.currentPage] = updated
336
337 return a, itemCmd
338 case pubsub.Event[permission.PermissionRequest]:
339 return a, util.CmdHandler(dialogs.OpenDialogMsg{
340 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
341 DiffMode: config.Get().Options.TUI.DiffMode,
342 }),
343 })
344 case permissions.PermissionResponseMsg:
345 switch msg.Action {
346 case permissions.PermissionAllow:
347 a.app.Permissions.Grant(msg.Permission)
348 case permissions.PermissionAllowForSession:
349 a.app.Permissions.GrantPersistent(msg.Permission)
350 case permissions.PermissionDeny:
351 a.app.Permissions.Deny(msg.Permission)
352 }
353 return a, nil
354 case splash.OnboardingCompleteMsg:
355 item, ok := a.pages[a.currentPage]
356 if !ok {
357 return a, nil
358 }
359
360 a.isConfigured = config.HasInitialDataConfig()
361 updated, pageCmd := item.Update(msg)
362 a.pages[a.currentPage] = updated
363
364 cmds = append(cmds, pageCmd)
365 return a, tea.Batch(cmds...)
366
367 case tea.KeyPressMsg:
368 return a, a.handleKeyPressMsg(msg)
369
370 case tea.MouseWheelMsg:
371 if a.dialog.HasDialogs() {
372 u, dialogCmd := a.dialog.Update(msg)
373 a.dialog = u.(dialogs.DialogCmp)
374 cmds = append(cmds, dialogCmd)
375 } else {
376 item, ok := a.pages[a.currentPage]
377 if !ok {
378 return a, nil
379 }
380
381 updated, pageCmd := item.Update(msg)
382 a.pages[a.currentPage] = updated
383
384 cmds = append(cmds, pageCmd)
385 }
386 return a, tea.Batch(cmds...)
387 case tea.PasteMsg:
388 if a.dialog.HasDialogs() {
389 u, dialogCmd := a.dialog.Update(msg)
390 if model, ok := u.(dialogs.DialogCmp); ok {
391 a.dialog = model
392 }
393
394 cmds = append(cmds, dialogCmd)
395 } else {
396 item, ok := a.pages[a.currentPage]
397 if !ok {
398 return a, nil
399 }
400
401 updated, pageCmd := item.Update(msg)
402 a.pages[a.currentPage] = updated
403
404 cmds = append(cmds, pageCmd)
405 }
406 return a, tea.Batch(cmds...)
407 // Update Available
408 case pubsub.UpdateAvailableMsg:
409 // Show update notification in status bar
410 statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
411 if msg.IsDevelopment {
412 statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
413 }
414 s, statusCmd := a.status.Update(util.InfoMsg{
415 Type: util.InfoTypeUpdate,
416 Msg: statusMsg,
417 TTL: 10 * time.Second,
418 })
419 a.status = s.(status.StatusCmp)
420 return a, statusCmd
421 }
422 s, _ := a.status.Update(msg)
423 a.status = s.(status.StatusCmp)
424
425 item, ok := a.pages[a.currentPage]
426 if !ok {
427 return a, nil
428 }
429
430 updated, cmd := item.Update(msg)
431 a.pages[a.currentPage] = updated
432
433 if a.dialog.HasDialogs() {
434 u, dialogCmd := a.dialog.Update(msg)
435 if model, ok := u.(dialogs.DialogCmp); ok {
436 a.dialog = model
437 }
438
439 cmds = append(cmds, dialogCmd)
440 }
441 cmds = append(cmds, cmd)
442 return a, tea.Batch(cmds...)
443}
444
445// handleWindowResize processes window resize events and updates all components.
446func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
447 var cmds []tea.Cmd
448
449 // TODO: clean up these magic numbers.
450 if a.showingFullHelp {
451 height -= 5
452 } else {
453 height -= 2
454 }
455
456 a.width, a.height = width, height
457 // Update status bar
458 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
459 if model, ok := s.(status.StatusCmp); ok {
460 a.status = model
461 }
462 cmds = append(cmds, cmd)
463
464 // Update the current view.
465 for p, page := range a.pages {
466 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
467 a.pages[p] = updated
468
469 cmds = append(cmds, pageCmd)
470 }
471
472 // Update the dialogs
473 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
474 if model, ok := dialog.(dialogs.DialogCmp); ok {
475 a.dialog = model
476 }
477
478 cmds = append(cmds, cmd)
479
480 return tea.Batch(cmds...)
481}
482
483// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
484func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
485 // Check this first as the user should be able to quit no matter what.
486 if key.Matches(msg, a.keyMap.Quit) {
487 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
488 return tea.Quit
489 }
490 return util.CmdHandler(dialogs.OpenDialogMsg{
491 Model: quit.NewQuitDialog(),
492 })
493 }
494
495 if a.completions.Open() {
496 // completions
497 keyMap := a.completions.KeyMap()
498 switch {
499 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
500 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
501 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
502 u, cmd := a.completions.Update(msg)
503 a.completions = u.(completions.Completions)
504 return cmd
505 }
506 }
507 if a.dialog.HasDialogs() {
508 u, dialogCmd := a.dialog.Update(msg)
509 a.dialog = u.(dialogs.DialogCmp)
510 return dialogCmd
511 }
512 switch {
513 // help
514 case key.Matches(msg, a.keyMap.Help):
515 a.status.ToggleFullHelp()
516 a.showingFullHelp = !a.showingFullHelp
517 return a.handleWindowResize(a.wWidth, a.wHeight)
518 // dialogs
519 case key.Matches(msg, a.keyMap.Commands):
520 // if the app is not configured show no commands
521 if !a.isConfigured {
522 return nil
523 }
524 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
525 return util.CmdHandler(dialogs.CloseDialogMsg{})
526 }
527 if a.dialog.HasDialogs() {
528 return nil
529 }
530 return util.CmdHandler(dialogs.OpenDialogMsg{
531 Model: commands.NewCommandDialog(a.selectedSessionID),
532 })
533 case key.Matches(msg, a.keyMap.Models):
534 // if the app is not configured show no models
535 if !a.isConfigured {
536 return nil
537 }
538 if a.dialog.ActiveDialogID() == models.ModelsDialogID {
539 return util.CmdHandler(dialogs.CloseDialogMsg{})
540 }
541 if a.dialog.HasDialogs() {
542 return nil
543 }
544 return util.CmdHandler(dialogs.OpenDialogMsg{
545 Model: models.NewModelDialogCmp(),
546 })
547 case key.Matches(msg, a.keyMap.Sessions):
548 // if the app is not configured show no sessions
549 if !a.isConfigured {
550 return nil
551 }
552 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
553 return util.CmdHandler(dialogs.CloseDialogMsg{})
554 }
555 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
556 return nil
557 }
558 var cmds []tea.Cmd
559 cmds = append(cmds,
560 func() tea.Msg {
561 allSessions, _ := a.app.Sessions.List(context.Background())
562 return dialogs.OpenDialogMsg{
563 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
564 }
565 },
566 )
567 return tea.Sequence(cmds...)
568 case key.Matches(msg, a.keyMap.Suspend):
569 if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
570 return util.ReportWarn("Agent is busy, please wait...")
571 }
572 return tea.Suspend
573 default:
574 item, ok := a.pages[a.currentPage]
575 if !ok {
576 return nil
577 }
578
579 updated, cmd := item.Update(msg)
580 a.pages[a.currentPage] = updated
581 return cmd
582 }
583}
584
585// moveToPage handles navigation between different pages in the application.
586func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
587 if a.app.AgentCoordinator.IsBusy() {
588 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
589 return util.ReportWarn("Agent is busy, please wait...")
590 }
591
592 var cmds []tea.Cmd
593 if _, ok := a.loadedPages[pageID]; !ok {
594 cmd := a.pages[pageID].Init()
595 cmds = append(cmds, cmd)
596 a.loadedPages[pageID] = true
597 }
598 a.previousPage = a.currentPage
599 a.currentPage = pageID
600 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
601 cmd := sizable.SetSize(a.width, a.height)
602 cmds = append(cmds, cmd)
603 }
604
605 return tea.Batch(cmds...)
606}
607
608// View renders the complete application interface including pages, dialogs, and overlays.
609func (a *appModel) View() tea.View {
610 var view tea.View
611 t := styles.CurrentTheme()
612 view.AltScreen = true
613 view.MouseMode = tea.MouseModeCellMotion
614 view.BackgroundColor = t.BgBase
615 if a.wWidth < 25 || a.wHeight < 15 {
616 view.SetContent(
617 lipgloss.NewCanvas(
618 lipgloss.NewLayer(
619 t.S().Base.Width(a.wWidth).Height(a.wHeight).
620 Align(lipgloss.Center, lipgloss.Center).
621 Render(
622 t.S().Base.
623 Padding(1, 4).
624 Foreground(t.White).
625 BorderStyle(lipgloss.RoundedBorder()).
626 BorderForeground(t.Primary).
627 Render("Window too small!"),
628 ),
629 ),
630 ).Render(),
631 )
632 return view
633 }
634
635 page := a.pages[a.currentPage]
636 if withHelp, ok := page.(core.KeyMapHelp); ok {
637 a.status.SetKeyMap(withHelp.Help())
638 }
639 pageView := page.View()
640 components := []string{
641 pageView,
642 }
643 components = append(components, a.status.View())
644
645 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
646 layers := []*lipgloss.Layer{
647 lipgloss.NewLayer(appView),
648 }
649 if a.dialog.HasDialogs() {
650 layers = append(
651 layers,
652 a.dialog.GetLayers()...,
653 )
654 }
655
656 var cursor *tea.Cursor
657 if v, ok := page.(util.Cursor); ok {
658 cursor = v.Cursor()
659 // Hide the cursor if it's positioned outside the textarea
660 statusHeight := a.height - strings.Count(pageView, "\n") + 1
661 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
662 cursor = nil
663 }
664 }
665 activeView := a.dialog.ActiveModel()
666 if activeView != nil {
667 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
668 if v, ok := activeView.(util.Cursor); ok {
669 cursor = v.Cursor()
670 }
671 }
672
673 if a.completions.Open() && cursor != nil {
674 cmp := a.completions.View()
675 x, y := a.completions.Position()
676 layers = append(
677 layers,
678 lipgloss.NewLayer(cmp).X(x).Y(y),
679 )
680 }
681
682 canvas := lipgloss.NewCanvas(
683 layers...,
684 )
685
686 view.Content = canvas.Render()
687 view.Cursor = cursor
688
689 if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
690 // HACK: use a random percentage to prevent ghostty from hiding it
691 // after a timeout.
692 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
693 }
694 return view
695}
696
697func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
698 return func() tea.Msg {
699 a.app.UpdateAgentModel(ctx)
700 return nil
701 }
702}
703
704func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
705 return func() tea.Msg {
706 mcp.RefreshPrompts(ctx, name)
707 return nil
708 }
709}
710
711func handleMCPToolsEvent(ctx context.Context, name string, coordinator agent.Coordinator) tea.Cmd {
712 return func() tea.Msg {
713 mcp.RefreshTools(ctx, name)
714 // Refresh agent tools to pick up the new MCP tools.
715 if coordinator != nil {
716 if err := coordinator.RefreshTools(ctx); err != nil {
717 slog.Error("failed to refresh agent tools", "error", err)
718 }
719 }
720 return nil
721 }
722}
723
724// New creates and initializes a new TUI application model.
725func New(app *app.App) *appModel {
726 chatPage := chat.New(app)
727 keyMap := DefaultKeyMap()
728 keyMap.pageBindings = chatPage.Bindings()
729
730 model := &appModel{
731 currentPage: chat.ChatPageID,
732 app: app,
733 status: status.NewStatusCmp(),
734 loadedPages: make(map[page.PageID]bool),
735 keyMap: keyMap,
736
737 pages: map[page.PageID]util.Model{
738 chat.ChatPageID: chatPage,
739 },
740
741 dialog: dialogs.NewDialogCmp(),
742 completions: completions.New(),
743 }
744
745 return model
746}