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