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/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}