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