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.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
618			Align(lipgloss.Center, lipgloss.Center).
619			Render(t.S().Base.
620				Padding(1, 4).
621				Foreground(t.White).
622				BorderStyle(lipgloss.RoundedBorder()).
623				BorderForeground(t.Primary).
624				Render("Window too small!"),
625			)
626		return view
627	}
628
629	page := a.pages[a.currentPage]
630	if withHelp, ok := page.(core.KeyMapHelp); ok {
631		a.status.SetKeyMap(withHelp.Help())
632	}
633	pageView := page.View()
634	components := []string{
635		pageView,
636	}
637	components = append(components, a.status.View())
638
639	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
640	layers := []*lipgloss.Layer{
641		lipgloss.NewLayer(appView),
642	}
643	if a.dialog.HasDialogs() {
644		layers = append(
645			layers,
646			a.dialog.GetLayers()...,
647		)
648	}
649
650	var cursor *tea.Cursor
651	if v, ok := page.(util.Cursor); ok {
652		cursor = v.Cursor()
653		// Hide the cursor if it's positioned outside the textarea
654		statusHeight := a.height - strings.Count(pageView, "\n") + 1
655		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
656			cursor = nil
657		}
658	}
659	activeView := a.dialog.ActiveModel()
660	if activeView != nil {
661		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
662		if v, ok := activeView.(util.Cursor); ok {
663			cursor = v.Cursor()
664		}
665	}
666
667	if a.completions.Open() && cursor != nil {
668		cmp := a.completions.View()
669		x, y := a.completions.Position()
670		layers = append(
671			layers,
672			lipgloss.NewLayer(cmp).X(x).Y(y),
673		)
674	}
675
676	comp := lipgloss.NewCompositor(layers...)
677	view.Content = comp.Render()
678	view.Cursor = cursor
679
680	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
681		// HACK: use a random percentage to prevent ghostty from hiding it
682		// after a timeout.
683		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
684	}
685	return view
686}
687
688func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
689	return func() tea.Msg {
690		a.app.UpdateAgentModel(ctx)
691		return nil
692	}
693}
694
695func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
696	return func() tea.Msg {
697		mcp.RefreshPrompts(ctx, name)
698		return nil
699	}
700}
701
702func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
703	return func() tea.Msg {
704		mcp.RefreshTools(ctx, name)
705		return nil
706	}
707}
708
709// New creates and initializes a new TUI application model.
710func New(app *app.App) *appModel {
711	chatPage := chat.New(app)
712	keyMap := DefaultKeyMap()
713	keyMap.pageBindings = chatPage.Bindings()
714
715	model := &appModel{
716		currentPage: chat.ChatPageID,
717		app:         app,
718		status:      status.NewStatusCmp(),
719		loadedPages: make(map[page.PageID]bool),
720		keyMap:      keyMap,
721
722		pages: map[page.PageID]util.Model{
723			chat.ChatPageID: chatPage,
724		},
725
726		dialog:      dialogs.NewDialogCmp(),
727		completions: completions.New(),
728	}
729
730	return model
731}