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