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