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