tui.go

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