1package tui
2
3import (
4 "context"
5 "fmt"
6 "math/rand"
7 "regexp"
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 "git.secluded.site/crush/internal/agent/tools/mcp"
16 "git.secluded.site/crush/internal/app"
17 "git.secluded.site/crush/internal/config"
18 "git.secluded.site/crush/internal/event"
19 "git.secluded.site/crush/internal/notification"
20 "git.secluded.site/crush/internal/permission"
21 "git.secluded.site/crush/internal/pubsub"
22 "git.secluded.site/crush/internal/stringext"
23 cmpChat "git.secluded.site/crush/internal/tui/components/chat"
24 "git.secluded.site/crush/internal/tui/components/chat/splash"
25 "git.secluded.site/crush/internal/tui/components/completions"
26 "git.secluded.site/crush/internal/tui/components/core"
27 "git.secluded.site/crush/internal/tui/components/core/layout"
28 "git.secluded.site/crush/internal/tui/components/core/status"
29 "git.secluded.site/crush/internal/tui/components/dialogs"
30 "git.secluded.site/crush/internal/tui/components/dialogs/commands"
31 "git.secluded.site/crush/internal/tui/components/dialogs/filepicker"
32 "git.secluded.site/crush/internal/tui/components/dialogs/models"
33 "git.secluded.site/crush/internal/tui/components/dialogs/permissions"
34 "git.secluded.site/crush/internal/tui/components/dialogs/quit"
35 "git.secluded.site/crush/internal/tui/components/dialogs/sessions"
36 "git.secluded.site/crush/internal/tui/page"
37 "git.secluded.site/crush/internal/tui/page/chat"
38 "git.secluded.site/crush/internal/tui/styles"
39 "git.secluded.site/crush/internal/tui/util"
40 "github.com/charmbracelet/x/ansi"
41 "golang.org/x/mod/semver"
42 "golang.org/x/text/cases"
43 "golang.org/x/text/language"
44)
45
46var lastMouseEvent time.Time
47
48func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
49 switch msg.(type) {
50 case tea.MouseWheelMsg, tea.MouseMotionMsg:
51 now := time.Now()
52 // trackpad is sending too many requests
53 if now.Sub(lastMouseEvent) < 15*time.Millisecond {
54 return nil
55 }
56 lastMouseEvent = now
57 }
58 return msg
59}
60
61// appModel represents the main application model that manages pages, dialogs, and UI state.
62type appModel struct {
63 wWidth, wHeight int // Window dimensions
64 width, height int
65 keyMap KeyMap
66
67 currentPage page.PageID
68 previousPage page.PageID
69 pages map[page.PageID]util.Model
70 loadedPages map[page.PageID]bool
71
72 // Status
73 status status.StatusCmp
74 showingFullHelp bool
75
76 app *app.App
77
78 dialog dialogs.DialogCmp
79 completions completions.Completions
80 isConfigured bool
81
82 // Chat Page Specific
83 selectedSessionID string // The ID of the currently selected session
84
85 // sendProgressBar instructs the TUI to send progress bar updates to the
86 // terminal.
87 sendProgressBar bool
88
89 // QueryVersion instructs the TUI to query for the terminal version when it
90 // starts.
91 QueryVersion bool
92}
93
94// Init initializes the application model and returns initial commands.
95func (a appModel) Init() tea.Cmd {
96 item, ok := a.pages[a.currentPage]
97 if !ok {
98 return nil
99 }
100
101 var cmds []tea.Cmd
102 cmd := item.Init()
103 cmds = append(cmds, cmd)
104 a.loadedPages[a.currentPage] = true
105
106 cmd = a.status.Init()
107 cmds = append(cmds, cmd)
108 if a.QueryVersion {
109 cmds = append(cmds, tea.RequestTerminalVersion)
110 }
111
112 // Request focus event support from the terminal.
113 cmds = append(cmds, tea.Raw(ansi.RequestModeFocusEvent))
114
115 return tea.Batch(cmds...)
116}
117
118// Update handles incoming messages and updates the application state.
119func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120 var cmds []tea.Cmd
121 var cmd tea.Cmd
122 a.isConfigured = config.HasInitialDataConfig()
123
124 switch msg := msg.(type) {
125 case tea.ModeReportMsg:
126 if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
127 notification.SetFocusSupport(true)
128 notification.SetFocused(true)
129 }
130 return a, nil
131 case tea.FocusMsg:
132 notification.SetFocused(true)
133 return a, nil
134 case tea.BlurMsg:
135 notification.SetFocused(false)
136 return a, nil
137 case tea.EnvMsg:
138 // Is this Windows Terminal?
139 if !a.sendProgressBar {
140 a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
141 }
142 case tea.TerminalVersionMsg:
143 if a.sendProgressBar {
144 return a, nil
145 }
146 termVersion := strings.ToLower(msg.Name)
147 switch {
148 case stringext.ContainsAny(termVersion, "ghostty", "rio"):
149 a.sendProgressBar = true
150 case strings.Contains(termVersion, "iterm2"):
151 // iTerm2 supports progress bars from version v3.6.6
152 matches := regexp.MustCompile(`^iterm2 (\d+\.\d+\.\d+)$`).FindStringSubmatch(termVersion)
153 if len(matches) == 2 && semver.Compare("v"+matches[1], "v3.6.6") >= 0 {
154 a.sendProgressBar = true
155 }
156 }
157 return a, nil
158 case tea.KeyboardEnhancementsMsg:
159 // A non-zero value means we have key disambiguation support.
160 if msg.Flags > 0 {
161 a.keyMap.Models.SetHelp("ctrl+m", "models")
162 }
163 for id, page := range a.pages {
164 m, pageCmd := page.Update(msg)
165 a.pages[id] = m
166
167 if pageCmd != nil {
168 cmds = append(cmds, pageCmd)
169 }
170 }
171 return a, tea.Batch(cmds...)
172 case tea.WindowSizeMsg:
173 a.wWidth, a.wHeight = msg.Width, msg.Height
174 a.completions.Update(msg)
175 return a, a.handleWindowResize(msg.Width, msg.Height)
176
177 case pubsub.Event[mcp.Event]:
178 switch msg.Payload.Type {
179 case mcp.EventStateChanged:
180 return a, a.handleStateChanged(context.Background())
181 case mcp.EventPromptsListChanged:
182 return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
183 case mcp.EventToolsListChanged:
184 return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
185 }
186
187 // Completions messages
188 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
189 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
190 u, completionCmd := a.completions.Update(msg)
191 if model, ok := u.(completions.Completions); ok {
192 a.completions = model
193 }
194
195 return a, completionCmd
196
197 // Dialog messages
198 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
199 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
200 a.completions = u.(completions.Completions)
201 u, dialogCmd := a.dialog.Update(msg)
202 a.dialog = u.(dialogs.DialogCmp)
203 return a, tea.Batch(completionCmd, dialogCmd)
204 case commands.ShowArgumentsDialogMsg:
205 var args []commands.Argument
206 for _, arg := range msg.ArgNames {
207 args = append(args, commands.Argument{
208 Name: arg,
209 Title: cases.Title(language.English).String(arg),
210 Required: true,
211 })
212 }
213 return a, util.CmdHandler(
214 dialogs.OpenDialogMsg{
215 Model: commands.NewCommandArgumentsDialog(
216 msg.CommandID,
217 msg.CommandID,
218 msg.CommandID,
219 msg.Description,
220 args,
221 msg.OnSubmit,
222 ),
223 },
224 )
225 case commands.ShowMCPPromptArgumentsDialogMsg:
226 args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
227 for _, arg := range msg.Prompt.Arguments {
228 args = append(args, commands.Argument(*arg))
229 }
230 dialog := commands.NewCommandArgumentsDialog(
231 msg.Prompt.Name,
232 msg.Prompt.Title,
233 msg.Prompt.Name,
234 msg.Prompt.Description,
235 args,
236 msg.OnSubmit,
237 )
238 return a, util.CmdHandler(
239 dialogs.OpenDialogMsg{
240 Model: dialog,
241 },
242 )
243 // Page change messages
244 case page.PageChangeMsg:
245 return a, a.moveToPage(msg.ID)
246
247 // Status Messages
248 case util.InfoMsg, util.ClearStatusMsg:
249 s, statusCmd := a.status.Update(msg)
250 a.status = s.(status.StatusCmp)
251 cmds = append(cmds, statusCmd)
252 return a, tea.Batch(cmds...)
253
254 // Session
255 case cmpChat.SessionSelectedMsg:
256 a.selectedSessionID = msg.ID
257 case cmpChat.SessionClearedMsg:
258 a.selectedSessionID = ""
259 // Commands
260 case commands.SwitchSessionsMsg:
261 return a, func() tea.Msg {
262 allSessions, _ := a.app.Sessions.List(context.Background())
263 return dialogs.OpenDialogMsg{
264 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
265 }
266 }
267
268 case commands.SwitchModelMsg:
269 return a, util.CmdHandler(
270 dialogs.OpenDialogMsg{
271 Model: models.NewModelDialogCmp(),
272 },
273 )
274 // Compact
275 case commands.CompactMsg:
276 return a, func() tea.Msg {
277 err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
278 if err != nil {
279 return util.ReportError(err)()
280 }
281 return nil
282 }
283 case commands.QuitMsg:
284 return a, util.CmdHandler(dialogs.OpenDialogMsg{
285 Model: quit.NewQuitDialog(),
286 })
287 case commands.ToggleYoloModeMsg:
288 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
289 case commands.ToggleHelpMsg:
290 a.status.ToggleFullHelp()
291 a.showingFullHelp = !a.showingFullHelp
292 return a, a.handleWindowResize(a.wWidth, a.wHeight)
293 // Model Switch
294 case models.ModelSelectedMsg:
295 if a.app.AgentCoordinator.IsBusy() {
296 return a, util.ReportWarn("Agent is busy, please wait...")
297 }
298
299 cfg := config.Get()
300 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
301 return a, util.ReportError(err)
302 }
303
304 go a.app.UpdateAgentModel(context.TODO())
305
306 modelTypeName := "large"
307 if msg.ModelType == config.SelectedModelTypeSmall {
308 modelTypeName = "small"
309 }
310 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
311
312 // File Picker
313 case commands.OpenFilePickerMsg:
314 event.FilePickerOpened()
315
316 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
317 // If the commands dialog is already open, close it
318 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
319 }
320 return a, util.CmdHandler(dialogs.OpenDialogMsg{
321 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
322 })
323 // Permissions
324 case pubsub.Event[permission.PermissionNotification]:
325 item, ok := a.pages[a.currentPage]
326 if !ok {
327 return a, nil
328 }
329
330 // Forward to view.
331 updated, itemCmd := item.Update(msg)
332 a.pages[a.currentPage] = updated
333
334 return a, itemCmd
335 case pubsub.Event[permission.PermissionRequest]:
336 // Send notification if window is not focused.
337 notifBody := fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName)
338 _ = notification.Send("Crush is waiting...", notifBody)
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 view.ReportFocus = true
616 if a.wWidth < 25 || a.wHeight < 15 {
617 view.SetContent(
618 lipgloss.NewCanvas(
619 lipgloss.NewLayer(
620 t.S().Base.Width(a.wWidth).Height(a.wHeight).
621 Align(lipgloss.Center, lipgloss.Center).
622 Render(
623 t.S().Base.
624 Padding(1, 4).
625 Foreground(t.White).
626 BorderStyle(lipgloss.RoundedBorder()).
627 BorderForeground(t.Primary).
628 Render("Window too small!"),
629 ),
630 ),
631 ).Render(),
632 )
633 return view
634 }
635
636 page := a.pages[a.currentPage]
637 if withHelp, ok := page.(core.KeyMapHelp); ok {
638 a.status.SetKeyMap(withHelp.Help())
639 }
640 pageView := page.View()
641 components := []string{
642 pageView,
643 }
644 components = append(components, a.status.View())
645
646 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
647 layers := []*lipgloss.Layer{
648 lipgloss.NewLayer(appView),
649 }
650 if a.dialog.HasDialogs() {
651 layers = append(
652 layers,
653 a.dialog.GetLayers()...,
654 )
655 }
656
657 var cursor *tea.Cursor
658 if v, ok := page.(util.Cursor); ok {
659 cursor = v.Cursor()
660 // Hide the cursor if it's positioned outside the textarea
661 statusHeight := a.height - strings.Count(pageView, "\n") + 1
662 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
663 cursor = nil
664 }
665 }
666 activeView := a.dialog.ActiveModel()
667 if activeView != nil {
668 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
669 if v, ok := activeView.(util.Cursor); ok {
670 cursor = v.Cursor()
671 }
672 }
673
674 if a.completions.Open() && cursor != nil {
675 cmp := a.completions.View()
676 x, y := a.completions.Position()
677 layers = append(
678 layers,
679 lipgloss.NewLayer(cmp).X(x).Y(y),
680 )
681 }
682
683 canvas := lipgloss.NewCanvas(
684 layers...,
685 )
686
687 view.Content = canvas.Render()
688 view.Cursor = cursor
689
690 if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
691 // HACK: use a random percentage to prevent ghostty from hiding it
692 // after a timeout.
693 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
694 }
695 return view
696}
697
698func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
699 return func() tea.Msg {
700 a.app.UpdateAgentModel(ctx)
701 return nil
702 }
703}
704
705func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
706 return func() tea.Msg {
707 mcp.RefreshPrompts(ctx, name)
708 return nil
709 }
710}
711
712func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
713 return func() tea.Msg {
714 mcp.RefreshTools(ctx, name)
715 return nil
716 }
717}
718
719// New creates and initializes a new TUI application model.
720func New(app *app.App) *appModel {
721 chatPage := chat.New(app)
722 keyMap := DefaultKeyMap()
723 keyMap.pageBindings = chatPage.Bindings()
724
725 model := &appModel{
726 currentPage: chat.ChatPageID,
727 app: app,
728 status: status.NewStatusCmp(),
729 loadedPages: make(map[page.PageID]bool),
730 keyMap: keyMap,
731
732 pages: map[page.PageID]util.Model{
733 chat.ChatPageID: chatPage,
734 },
735
736 dialog: dialogs.NewDialogCmp(),
737 completions: completions.New(),
738 }
739
740 return model
741}