tui.go

  1package tui
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"math/rand"
  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"
 16	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 17	"github.com/charmbracelet/crush/internal/app"
 18	"github.com/charmbracelet/crush/internal/config"
 19	"github.com/charmbracelet/crush/internal/event"
 20	"github.com/charmbracelet/crush/internal/permission"
 21	"github.com/charmbracelet/crush/internal/pubsub"
 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	"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
 91// Init initializes the application model and returns initial commands.
 92func (a appModel) Init() tea.Cmd {
 93	item, ok := a.pages[a.currentPage]
 94	if !ok {
 95		return nil
 96	}
 97
 98	var cmds []tea.Cmd
 99	cmd := item.Init()
100	cmds = append(cmds, cmd)
101	a.loadedPages[a.currentPage] = true
102
103	cmd = a.status.Init()
104	cmds = append(cmds, cmd)
105	if a.QueryVersion {
106		cmds = append(cmds, tea.RequestTerminalVersion)
107	}
108
109	return tea.Batch(cmds...)
110}
111
112// Update handles incoming messages and updates the application state.
113func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114	var cmds []tea.Cmd
115	var cmd tea.Cmd
116	a.isConfigured = config.HasInitialDataConfig()
117
118	switch msg := msg.(type) {
119	case tea.EnvMsg:
120		// Is this Windows Terminal?
121		if !a.sendProgressBar {
122			a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
123		}
124	case tea.TerminalVersionMsg:
125		termVersion := strings.ToLower(msg.Name)
126		// Only enable progress bar for the following terminals.
127		if !a.sendProgressBar {
128			a.sendProgressBar = strings.Contains(termVersion, "ghostty")
129		}
130		return a, nil
131	case tea.KeyboardEnhancementsMsg:
132		// A non-zero value means we have key disambiguation support.
133		if msg.Flags > 0 {
134			a.keyMap.Models.SetHelp("ctrl+m", "models")
135		}
136		for id, page := range a.pages {
137			m, pageCmd := page.Update(msg)
138			a.pages[id] = m
139
140			if pageCmd != nil {
141				cmds = append(cmds, pageCmd)
142			}
143		}
144		return a, tea.Batch(cmds...)
145	case tea.WindowSizeMsg:
146		a.wWidth, a.wHeight = msg.Width, msg.Height
147		a.completions.Update(msg)
148		return a, a.handleWindowResize(msg.Width, msg.Height)
149
150	case pubsub.Event[mcp.Event]:
151		switch msg.Payload.Type {
152		case mcp.EventStateChanged:
153			return a, a.handleStateChanged(context.Background())
154		case mcp.EventPromptsListChanged:
155			return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
156		case mcp.EventToolsListChanged:
157			return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name, a.app.AgentCoordinator)
158		}
159
160	// Completions messages
161	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
162		completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
163		u, completionCmd := a.completions.Update(msg)
164		if model, ok := u.(completions.Completions); ok {
165			a.completions = model
166		}
167
168		return a, completionCmd
169
170	// Dialog messages
171	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
172		u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
173		a.completions = u.(completions.Completions)
174		u, dialogCmd := a.dialog.Update(msg)
175		a.dialog = u.(dialogs.DialogCmp)
176		return a, tea.Batch(completionCmd, dialogCmd)
177	case commands.ShowArgumentsDialogMsg:
178		var args []commands.Argument
179		for _, arg := range msg.ArgNames {
180			args = append(args, commands.Argument{
181				Name:     arg,
182				Title:    cases.Title(language.English).String(arg),
183				Required: true,
184			})
185		}
186		return a, util.CmdHandler(
187			dialogs.OpenDialogMsg{
188				Model: commands.NewCommandArgumentsDialog(
189					msg.CommandID,
190					msg.CommandID,
191					msg.CommandID,
192					msg.Description,
193					args,
194					msg.OnSubmit,
195				),
196			},
197		)
198	case commands.ShowMCPPromptArgumentsDialogMsg:
199		args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
200		for _, arg := range msg.Prompt.Arguments {
201			args = append(args, commands.Argument(*arg))
202		}
203		dialog := commands.NewCommandArgumentsDialog(
204			msg.Prompt.Name,
205			msg.Prompt.Title,
206			msg.Prompt.Name,
207			msg.Prompt.Description,
208			args,
209			msg.OnSubmit,
210		)
211		return a, util.CmdHandler(
212			dialogs.OpenDialogMsg{
213				Model: dialog,
214			},
215		)
216	// Page change messages
217	case page.PageChangeMsg:
218		return a, a.moveToPage(msg.ID)
219
220	// Status Messages
221	case util.InfoMsg, util.ClearStatusMsg:
222		s, statusCmd := a.status.Update(msg)
223		a.status = s.(status.StatusCmp)
224		cmds = append(cmds, statusCmd)
225		return a, tea.Batch(cmds...)
226
227	// Session
228	case cmpChat.SessionSelectedMsg:
229		a.selectedSessionID = msg.ID
230	case cmpChat.SessionClearedMsg:
231		a.selectedSessionID = ""
232	// Commands
233	case commands.SwitchSessionsMsg:
234		return a, func() tea.Msg {
235			allSessions, _ := a.app.Sessions.List(context.Background())
236			return dialogs.OpenDialogMsg{
237				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
238			}
239		}
240
241	case commands.SwitchModelMsg:
242		return a, util.CmdHandler(
243			dialogs.OpenDialogMsg{
244				Model: models.NewModelDialogCmp(),
245			},
246		)
247	// Compact
248	case commands.CompactMsg:
249		return a, func() tea.Msg {
250			err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
251			if err != nil {
252				return util.ReportError(err)()
253			}
254			return nil
255		}
256	case commands.QuitMsg:
257		return a, util.CmdHandler(dialogs.OpenDialogMsg{
258			Model: quit.NewQuitDialog(),
259		})
260	case commands.EnableDockerMCPMsg:
261		return a, func() tea.Msg {
262			cfg := config.Get()
263			if err := cfg.EnableDockerMCP(); err != nil {
264				return util.ReportError(err)()
265			}
266
267			// Initialize the Docker MCP client immediately.
268			ctx := context.Background()
269			if err := mcp.InitializeSingle(ctx, config.DockerMCPName, cfg); err != nil {
270				return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
271			}
272
273			return util.ReportInfo("Docker MCP enabled and started successfully")()
274		}
275	case commands.DisableDockerMCPMsg:
276		return a, func() tea.Msg {
277			// Close the Docker MCP client.
278			if err := mcp.DisableSingle(config.DockerMCPName); err != nil {
279				return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
280			}
281
282			// Remove from config and persist.
283			cfg := config.Get()
284			if err := cfg.DisableDockerMCP(); err != nil {
285				return util.ReportError(err)()
286			}
287
288			return util.ReportInfo("Docker MCP disabled successfully")()
289		}
290	case commands.ToggleYoloModeMsg:
291		a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
292	case commands.ToggleHelpMsg:
293		a.status.ToggleFullHelp()
294		a.showingFullHelp = !a.showingFullHelp
295		return a, a.handleWindowResize(a.wWidth, a.wHeight)
296	// Model Switch
297	case models.ModelSelectedMsg:
298		if a.app.AgentCoordinator.IsBusy() {
299			return a, util.ReportWarn("Agent is busy, please wait...")
300		}
301
302		cfg := config.Get()
303		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
304			return a, util.ReportError(err)
305		}
306
307		go a.app.UpdateAgentModel(context.TODO())
308
309		modelTypeName := "large"
310		if msg.ModelType == config.SelectedModelTypeSmall {
311			modelTypeName = "small"
312		}
313		return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
314
315	// File Picker
316	case commands.OpenFilePickerMsg:
317		event.FilePickerOpened()
318
319		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
320			// If the commands dialog is already open, close it
321			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
322		}
323		return a, util.CmdHandler(dialogs.OpenDialogMsg{
324			Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
325		})
326	// Permissions
327	case pubsub.Event[permission.PermissionNotification]:
328		item, ok := a.pages[a.currentPage]
329		if !ok {
330			return a, nil
331		}
332
333		// Forward to view.
334		updated, itemCmd := item.Update(msg)
335		a.pages[a.currentPage] = updated
336
337		return a, itemCmd
338	case pubsub.Event[permission.PermissionRequest]:
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	if a.wWidth < 25 || a.wHeight < 15 {
616		view.SetContent(
617			lipgloss.NewCanvas(
618				lipgloss.NewLayer(
619					t.S().Base.Width(a.wWidth).Height(a.wHeight).
620						Align(lipgloss.Center, lipgloss.Center).
621						Render(
622							t.S().Base.
623								Padding(1, 4).
624								Foreground(t.White).
625								BorderStyle(lipgloss.RoundedBorder()).
626								BorderForeground(t.Primary).
627								Render("Window too small!"),
628						),
629				),
630			).Render(),
631		)
632		return view
633	}
634
635	page := a.pages[a.currentPage]
636	if withHelp, ok := page.(core.KeyMapHelp); ok {
637		a.status.SetKeyMap(withHelp.Help())
638	}
639	pageView := page.View()
640	components := []string{
641		pageView,
642	}
643	components = append(components, a.status.View())
644
645	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
646	layers := []*lipgloss.Layer{
647		lipgloss.NewLayer(appView),
648	}
649	if a.dialog.HasDialogs() {
650		layers = append(
651			layers,
652			a.dialog.GetLayers()...,
653		)
654	}
655
656	var cursor *tea.Cursor
657	if v, ok := page.(util.Cursor); ok {
658		cursor = v.Cursor()
659		// Hide the cursor if it's positioned outside the textarea
660		statusHeight := a.height - strings.Count(pageView, "\n") + 1
661		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
662			cursor = nil
663		}
664	}
665	activeView := a.dialog.ActiveModel()
666	if activeView != nil {
667		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
668		if v, ok := activeView.(util.Cursor); ok {
669			cursor = v.Cursor()
670		}
671	}
672
673	if a.completions.Open() && cursor != nil {
674		cmp := a.completions.View()
675		x, y := a.completions.Position()
676		layers = append(
677			layers,
678			lipgloss.NewLayer(cmp).X(x).Y(y),
679		)
680	}
681
682	canvas := lipgloss.NewCanvas(
683		layers...,
684	)
685
686	view.Content = canvas.Render()
687	view.Cursor = cursor
688
689	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
690		// HACK: use a random percentage to prevent ghostty from hiding it
691		// after a timeout.
692		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
693	}
694	return view
695}
696
697func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
698	return func() tea.Msg {
699		a.app.UpdateAgentModel(ctx)
700		return nil
701	}
702}
703
704func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
705	return func() tea.Msg {
706		mcp.RefreshPrompts(ctx, name)
707		return nil
708	}
709}
710
711func handleMCPToolsEvent(ctx context.Context, name string, coordinator agent.Coordinator) tea.Cmd {
712	return func() tea.Msg {
713		mcp.RefreshTools(ctx, name)
714		// Refresh agent tools to pick up the new MCP tools.
715		if coordinator != nil {
716			if err := coordinator.RefreshTools(ctx); err != nil {
717				slog.Error("failed to refresh agent tools", "error", err)
718			}
719		}
720		return nil
721	}
722}
723
724// New creates and initializes a new TUI application model.
725func New(app *app.App) *appModel {
726	chatPage := chat.New(app)
727	keyMap := DefaultKeyMap()
728	keyMap.pageBindings = chatPage.Bindings()
729
730	model := &appModel{
731		currentPage: chat.ChatPageID,
732		app:         app,
733		status:      status.NewStatusCmp(),
734		loadedPages: make(map[page.PageID]bool),
735		keyMap:      keyMap,
736
737		pages: map[page.PageID]util.Model{
738			chat.ChatPageID: chatPage,
739		},
740
741		dialog:      dialogs.NewDialogCmp(),
742		completions: completions.New(),
743	}
744
745	return model
746}