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