tui.go

  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	}
384	s, _ := a.status.Update(msg)
385	a.status = s.(status.StatusCmp)
386
387	item, ok := a.pages[a.currentPage]
388	if !ok {
389		return a, nil
390	}
391
392	updated, cmd := item.Update(msg)
393	a.pages[a.currentPage] = updated
394
395	if a.dialog.HasDialogs() {
396		u, dialogCmd := a.dialog.Update(msg)
397		if model, ok := u.(dialogs.DialogCmp); ok {
398			a.dialog = model
399		}
400
401		cmds = append(cmds, dialogCmd)
402	}
403	cmds = append(cmds, cmd)
404	return a, tea.Batch(cmds...)
405}
406
407// handleWindowResize processes window resize events and updates all components.
408func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
409	var cmds []tea.Cmd
410
411	// TODO: clean up these magic numbers.
412	if a.showingFullHelp {
413		height -= 5
414	} else {
415		height -= 2
416	}
417
418	a.width, a.height = width, height
419	// Update status bar
420	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
421	if model, ok := s.(status.StatusCmp); ok {
422		a.status = model
423	}
424	cmds = append(cmds, cmd)
425
426	// Update the current view.
427	for p, page := range a.pages {
428		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
429		a.pages[p] = updated
430
431		cmds = append(cmds, pageCmd)
432	}
433
434	// Update the dialogs
435	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
436	if model, ok := dialog.(dialogs.DialogCmp); ok {
437		a.dialog = model
438	}
439
440	cmds = append(cmds, cmd)
441
442	return tea.Batch(cmds...)
443}
444
445// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
446func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
447	// Check this first as the user should be able to quit no matter what.
448	if key.Matches(msg, a.keyMap.Quit) {
449		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
450			return tea.Quit
451		}
452		return util.CmdHandler(dialogs.OpenDialogMsg{
453			Model: quit.NewQuitDialog(),
454		})
455	}
456
457	if a.completions.Open() {
458		// completions
459		keyMap := a.completions.KeyMap()
460		switch {
461		case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
462			key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
463			key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
464			u, cmd := a.completions.Update(msg)
465			a.completions = u.(completions.Completions)
466			return cmd
467		}
468	}
469	if a.dialog.HasDialogs() {
470		u, dialogCmd := a.dialog.Update(msg)
471		a.dialog = u.(dialogs.DialogCmp)
472		return dialogCmd
473	}
474	switch {
475	// help
476	case key.Matches(msg, a.keyMap.Help):
477		a.status.ToggleFullHelp()
478		a.showingFullHelp = !a.showingFullHelp
479		return a.handleWindowResize(a.wWidth, a.wHeight)
480	// dialogs
481	case key.Matches(msg, a.keyMap.Commands):
482		// Opening the command palette counts as interaction; cancel pending
483		// turn-end notification for the selected session if any.
484		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
485			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
486		}
487		// if the app is not configured show no commands
488		if !a.isConfigured {
489			return nil
490		}
491		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
492			return util.CmdHandler(dialogs.CloseDialogMsg{})
493		}
494		if a.dialog.HasDialogs() {
495			return nil
496		}
497		return util.CmdHandler(dialogs.OpenDialogMsg{
498			Model: commands.NewCommandDialog(a.selectedSessionID),
499		})
500	case key.Matches(msg, a.keyMap.Models):
501		// if the app is not configured show no models
502		if !a.isConfigured {
503			return nil
504		}
505		if a.dialog.ActiveDialogID() == models.ModelsDialogID {
506			return util.CmdHandler(dialogs.CloseDialogMsg{})
507		}
508		if a.dialog.HasDialogs() {
509			return nil
510		}
511		return util.CmdHandler(dialogs.OpenDialogMsg{
512			Model: models.NewModelDialogCmp(),
513		})
514	case key.Matches(msg, a.keyMap.Sessions):
515		// if the app is not configured show no sessions
516		if !a.isConfigured {
517			return nil
518		}
519		// Opening sessions dialog is interaction; cancel pending turn-end notif.
520		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
521			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
522		}
523		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
524			return util.CmdHandler(dialogs.CloseDialogMsg{})
525		}
526		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
527			return nil
528		}
529		var cmds []tea.Cmd
530		cmds = append(cmds,
531			func() tea.Msg {
532				allSessions, _ := a.app.Sessions.List(context.Background())
533				return dialogs.OpenDialogMsg{
534					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
535				}
536			},
537		)
538		return tea.Sequence(cmds...)
539	case key.Matches(msg, a.keyMap.Suspend):
540		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
541			return util.ReportWarn("Agent is busy, please wait...")
542		}
543		return tea.Suspend
544	default:
545		item, ok := a.pages[a.currentPage]
546		if !ok {
547			return nil
548		}
549
550		updated, cmd := item.Update(msg)
551		a.pages[a.currentPage] = updated
552		return cmd
553	}
554}
555
556// moveToPage handles navigation between different pages in the application.
557func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
558	if a.app.AgentCoordinator.IsBusy() {
559		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
560		return util.ReportWarn("Agent is busy, please wait...")
561	}
562
563	var cmds []tea.Cmd
564	if _, ok := a.loadedPages[pageID]; !ok {
565		cmd := a.pages[pageID].Init()
566		cmds = append(cmds, cmd)
567		a.loadedPages[pageID] = true
568	}
569	a.previousPage = a.currentPage
570	a.currentPage = pageID
571	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
572		cmd := sizable.SetSize(a.width, a.height)
573		cmds = append(cmds, cmd)
574	}
575
576	return tea.Batch(cmds...)
577}
578
579// View renders the complete application interface including pages, dialogs, and overlays.
580func (a *appModel) View() tea.View {
581	var view tea.View
582	t := styles.CurrentTheme()
583	view.AltScreen = true
584	view.MouseMode = tea.MouseModeCellMotion
585	view.BackgroundColor = t.BgBase
586	if a.wWidth < 25 || a.wHeight < 15 {
587		view.SetContent(
588			lipgloss.NewCanvas(
589				lipgloss.NewLayer(
590					t.S().Base.Width(a.wWidth).Height(a.wHeight).
591						Align(lipgloss.Center, lipgloss.Center).
592						Render(
593							t.S().Base.
594								Padding(1, 4).
595								Foreground(t.White).
596								BorderStyle(lipgloss.RoundedBorder()).
597								BorderForeground(t.Primary).
598								Render("Window too small!"),
599						),
600				),
601			),
602		)
603		return view
604	}
605
606	page := a.pages[a.currentPage]
607	if withHelp, ok := page.(core.KeyMapHelp); ok {
608		a.status.SetKeyMap(withHelp.Help())
609	}
610	pageView := page.View()
611	components := []string{
612		pageView,
613	}
614	components = append(components, a.status.View())
615
616	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
617	layers := []*lipgloss.Layer{
618		lipgloss.NewLayer(appView),
619	}
620	if a.dialog.HasDialogs() {
621		layers = append(
622			layers,
623			a.dialog.GetLayers()...,
624		)
625	}
626
627	var cursor *tea.Cursor
628	if v, ok := page.(util.Cursor); ok {
629		cursor = v.Cursor()
630		// Hide the cursor if it's positioned outside the textarea
631		statusHeight := a.height - strings.Count(pageView, "\n") + 1
632		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
633			cursor = nil
634		}
635	}
636	activeView := a.dialog.ActiveModel()
637	if activeView != nil {
638		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
639		if v, ok := activeView.(util.Cursor); ok {
640			cursor = v.Cursor()
641		}
642	}
643
644	if a.completions.Open() && cursor != nil {
645		cmp := a.completions.View()
646		x, y := a.completions.Position()
647		layers = append(
648			layers,
649			lipgloss.NewLayer(cmp).X(x).Y(y),
650		)
651	}
652
653	canvas := lipgloss.NewCanvas(
654		layers...,
655	)
656
657	view.Content = canvas
658	view.Cursor = cursor
659
660	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
661		// HACK: use a random percentage to prevent ghostty from hiding it
662		// after a timeout.
663		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
664	}
665	return view
666}
667
668func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
669	return func() tea.Msg {
670		a.app.UpdateAgentModel(ctx)
671		return nil
672	}
673}
674
675func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
676	return func() tea.Msg {
677		mcp.RefreshPrompts(ctx, name)
678		return nil
679	}
680}
681
682func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
683	return func() tea.Msg {
684		mcp.RefreshTools(ctx, name)
685		return nil
686	}
687}
688
689// New creates and initializes a new TUI application model.
690func New(app *app.App) *appModel {
691	chatPage := chat.New(app)
692	keyMap := DefaultKeyMap()
693	keyMap.pageBindings = chatPage.Bindings()
694
695	model := &appModel{
696		currentPage: chat.ChatPageID,
697		app:         app,
698		status:      status.NewStatusCmp(),
699		loadedPages: make(map[page.PageID]bool),
700		keyMap:      keyMap,
701
702		pages: map[page.PageID]util.Model{
703			chat.ChatPageID: chatPage,
704		},
705
706		dialog:      dialogs.NewDialogCmp(),
707		completions: completions.New(),
708	}
709
710	return model
711}