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