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/permission"
 19	"github.com/charmbracelet/crush/internal/pubsub"
 20	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 21	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
 22	"github.com/charmbracelet/crush/internal/tui/components/completions"
 23	"github.com/charmbracelet/crush/internal/tui/components/core"
 24	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 25	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 26	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 27	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 28	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 29	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 30	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 31	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 32	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
 33	"github.com/charmbracelet/crush/internal/tui/page"
 34	"github.com/charmbracelet/crush/internal/tui/page/chat"
 35	"github.com/charmbracelet/crush/internal/tui/styles"
 36	"github.com/charmbracelet/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		return a, util.CmdHandler(
241			dialogs.OpenDialogMsg{
242				Model: models.NewModelDialogCmp(),
243			},
244		)
245	// Compact
246	case commands.CompactMsg:
247		return a, func() tea.Msg {
248			err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
249			if err != nil {
250				return util.ReportError(err)()
251			}
252			return nil
253		}
254	case commands.QuitMsg:
255		return a, util.CmdHandler(dialogs.OpenDialogMsg{
256			Model: quit.NewQuitDialog(),
257		})
258	case commands.ToggleYoloModeMsg:
259		a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
260	case commands.ToggleHelpMsg:
261		a.status.ToggleFullHelp()
262		a.showingFullHelp = !a.showingFullHelp
263		return a, a.handleWindowResize(a.wWidth, a.wHeight)
264	// Model Switch
265	case models.ModelSelectedMsg:
266		if a.app.AgentCoordinator.IsBusy() {
267			return a, util.ReportWarn("Agent is busy, please wait...")
268		}
269
270		cfg := config.Get()
271		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
272			return a, util.ReportError(err)
273		}
274
275		go a.app.UpdateAgentModel(context.TODO())
276
277		modelTypeName := "large"
278		if msg.ModelType == config.SelectedModelTypeSmall {
279			modelTypeName = "small"
280		}
281		return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
282
283	// File Picker
284	case commands.OpenFilePickerMsg:
285		event.FilePickerOpened()
286
287		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
288			// If the commands dialog is already open, close it
289			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
290		}
291		return a, util.CmdHandler(dialogs.OpenDialogMsg{
292			Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
293		})
294	// Permissions
295	case pubsub.Event[permission.PermissionNotification]:
296		item, ok := a.pages[a.currentPage]
297		if !ok {
298			return a, nil
299		}
300
301		// Forward to view.
302		updated, itemCmd := item.Update(msg)
303		a.pages[a.currentPage] = updated
304
305		return a, itemCmd
306	case pubsub.Event[permission.PermissionRequest]:
307		return a, util.CmdHandler(dialogs.OpenDialogMsg{
308			Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
309				DiffMode: config.Get().Options.TUI.DiffMode,
310			}),
311		})
312	case permissions.PermissionResponseMsg:
313		switch msg.Action {
314		case permissions.PermissionAllow:
315			a.app.Permissions.Grant(msg.Permission)
316		case permissions.PermissionAllowForSession:
317			a.app.Permissions.GrantPersistent(msg.Permission)
318		case permissions.PermissionDeny:
319			a.app.Permissions.Deny(msg.Permission)
320		}
321		return a, nil
322	case splash.OnboardingCompleteMsg:
323		item, ok := a.pages[a.currentPage]
324		if !ok {
325			return a, nil
326		}
327
328		a.isConfigured = config.HasInitialDataConfig()
329		updated, pageCmd := item.Update(msg)
330		a.pages[a.currentPage] = updated
331
332		cmds = append(cmds, pageCmd)
333		return a, tea.Batch(cmds...)
334
335	case tea.KeyPressMsg:
336		return a, a.handleKeyPressMsg(msg)
337
338	case tea.MouseWheelMsg:
339		if a.dialog.HasDialogs() {
340			u, dialogCmd := a.dialog.Update(msg)
341			a.dialog = u.(dialogs.DialogCmp)
342			cmds = append(cmds, dialogCmd)
343		} else {
344			item, ok := a.pages[a.currentPage]
345			if !ok {
346				return a, nil
347			}
348
349			updated, pageCmd := item.Update(msg)
350			a.pages[a.currentPage] = updated
351
352			cmds = append(cmds, pageCmd)
353		}
354		return a, tea.Batch(cmds...)
355	case tea.PasteMsg:
356		if a.dialog.HasDialogs() {
357			u, dialogCmd := a.dialog.Update(msg)
358			if model, ok := u.(dialogs.DialogCmp); ok {
359				a.dialog = model
360			}
361
362			cmds = append(cmds, dialogCmd)
363		} else {
364			item, ok := a.pages[a.currentPage]
365			if !ok {
366				return a, nil
367			}
368
369			updated, pageCmd := item.Update(msg)
370			a.pages[a.currentPage] = updated
371
372			cmds = append(cmds, pageCmd)
373		}
374		return a, tea.Batch(cmds...)
375	// Update Available
376	case pubsub.UpdateAvailableMsg:
377		// Show update notification in status bar
378		statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
379		if msg.IsDevelopment {
380			statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
381		}
382		s, statusCmd := a.status.Update(util.InfoMsg{
383			Type: util.InfoTypeInfo,
384			Msg:  statusMsg,
385			TTL:  30 * time.Second,
386		})
387		a.status = s.(status.StatusCmp)
388		return a, statusCmd
389	}
390	s, _ := a.status.Update(msg)
391	a.status = s.(status.StatusCmp)
392
393	item, ok := a.pages[a.currentPage]
394	if !ok {
395		return a, nil
396	}
397
398	updated, cmd := item.Update(msg)
399	a.pages[a.currentPage] = updated
400
401	if a.dialog.HasDialogs() {
402		u, dialogCmd := a.dialog.Update(msg)
403		if model, ok := u.(dialogs.DialogCmp); ok {
404			a.dialog = model
405		}
406
407		cmds = append(cmds, dialogCmd)
408	}
409	cmds = append(cmds, cmd)
410	return a, tea.Batch(cmds...)
411}
412
413// handleWindowResize processes window resize events and updates all components.
414func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
415	var cmds []tea.Cmd
416
417	// TODO: clean up these magic numbers.
418	if a.showingFullHelp {
419		height -= 5
420	} else {
421		height -= 2
422	}
423
424	a.width, a.height = width, height
425	// Update status bar
426	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
427	if model, ok := s.(status.StatusCmp); ok {
428		a.status = model
429	}
430	cmds = append(cmds, cmd)
431
432	// Update the current view.
433	for p, page := range a.pages {
434		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
435		a.pages[p] = updated
436
437		cmds = append(cmds, pageCmd)
438	}
439
440	// Update the dialogs
441	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
442	if model, ok := dialog.(dialogs.DialogCmp); ok {
443		a.dialog = model
444	}
445
446	cmds = append(cmds, cmd)
447
448	return tea.Batch(cmds...)
449}
450
451// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
452func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
453	// Check this first as the user should be able to quit no matter what.
454	if key.Matches(msg, a.keyMap.Quit) {
455		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
456			return tea.Quit
457		}
458		return util.CmdHandler(dialogs.OpenDialogMsg{
459			Model: quit.NewQuitDialog(),
460		})
461	}
462
463	if a.completions.Open() {
464		// completions
465		keyMap := a.completions.KeyMap()
466		switch {
467		case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
468			key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
469			key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
470			u, cmd := a.completions.Update(msg)
471			a.completions = u.(completions.Completions)
472			return cmd
473		}
474	}
475	if a.dialog.HasDialogs() {
476		u, dialogCmd := a.dialog.Update(msg)
477		a.dialog = u.(dialogs.DialogCmp)
478		return dialogCmd
479	}
480	switch {
481	// help
482	case key.Matches(msg, a.keyMap.Help):
483		a.status.ToggleFullHelp()
484		a.showingFullHelp = !a.showingFullHelp
485		return a.handleWindowResize(a.wWidth, a.wHeight)
486	// dialogs
487	case key.Matches(msg, a.keyMap.Commands):
488		// if the app is not configured show no commands
489		if !a.isConfigured {
490			return nil
491		}
492		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
493			return util.CmdHandler(dialogs.CloseDialogMsg{})
494		}
495		if a.dialog.HasDialogs() {
496			return nil
497		}
498		return util.CmdHandler(dialogs.OpenDialogMsg{
499			Model: commands.NewCommandDialog(a.selectedSessionID),
500		})
501	case key.Matches(msg, a.keyMap.Models):
502		// if the app is not configured show no models
503		if !a.isConfigured {
504			return nil
505		}
506		if a.dialog.ActiveDialogID() == models.ModelsDialogID {
507			return util.CmdHandler(dialogs.CloseDialogMsg{})
508		}
509		if a.dialog.HasDialogs() {
510			return nil
511		}
512		return util.CmdHandler(dialogs.OpenDialogMsg{
513			Model: models.NewModelDialogCmp(),
514		})
515	case key.Matches(msg, a.keyMap.Sessions):
516		// if the app is not configured show no sessions
517		if !a.isConfigured {
518			return nil
519		}
520		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
521			return util.CmdHandler(dialogs.CloseDialogMsg{})
522		}
523		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
524			return nil
525		}
526		var cmds []tea.Cmd
527		cmds = append(cmds,
528			func() tea.Msg {
529				allSessions, _ := a.app.Sessions.List(context.Background())
530				return dialogs.OpenDialogMsg{
531					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
532				}
533			},
534		)
535		return tea.Sequence(cmds...)
536	case key.Matches(msg, a.keyMap.Suspend):
537		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
538			return util.ReportWarn("Agent is busy, please wait...")
539		}
540		return tea.Suspend
541	default:
542		item, ok := a.pages[a.currentPage]
543		if !ok {
544			return nil
545		}
546
547		updated, cmd := item.Update(msg)
548		a.pages[a.currentPage] = updated
549		return cmd
550	}
551}
552
553// moveToPage handles navigation between different pages in the application.
554func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
555	if a.app.AgentCoordinator.IsBusy() {
556		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
557		return util.ReportWarn("Agent is busy, please wait...")
558	}
559
560	var cmds []tea.Cmd
561	if _, ok := a.loadedPages[pageID]; !ok {
562		cmd := a.pages[pageID].Init()
563		cmds = append(cmds, cmd)
564		a.loadedPages[pageID] = true
565	}
566	a.previousPage = a.currentPage
567	a.currentPage = pageID
568	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
569		cmd := sizable.SetSize(a.width, a.height)
570		cmds = append(cmds, cmd)
571	}
572
573	return tea.Batch(cmds...)
574}
575
576// View renders the complete application interface including pages, dialogs, and overlays.
577func (a *appModel) View() tea.View {
578	var view tea.View
579	t := styles.CurrentTheme()
580	view.AltScreen = true
581	view.MouseMode = tea.MouseModeCellMotion
582	view.BackgroundColor = t.BgBase
583	if a.wWidth < 25 || a.wHeight < 15 {
584		view.SetContent(
585			lipgloss.NewCanvas(
586				lipgloss.NewLayer(
587					t.S().Base.Width(a.wWidth).Height(a.wHeight).
588						Align(lipgloss.Center, lipgloss.Center).
589						Render(
590							t.S().Base.
591								Padding(1, 4).
592								Foreground(t.White).
593								BorderStyle(lipgloss.RoundedBorder()).
594								BorderForeground(t.Primary).
595								Render("Window too small!"),
596						),
597				),
598			),
599		)
600		return view
601	}
602
603	page := a.pages[a.currentPage]
604	if withHelp, ok := page.(core.KeyMapHelp); ok {
605		a.status.SetKeyMap(withHelp.Help())
606	}
607	pageView := page.View()
608	components := []string{
609		pageView,
610	}
611	components = append(components, a.status.View())
612
613	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
614	layers := []*lipgloss.Layer{
615		lipgloss.NewLayer(appView),
616	}
617	if a.dialog.HasDialogs() {
618		layers = append(
619			layers,
620			a.dialog.GetLayers()...,
621		)
622	}
623
624	var cursor *tea.Cursor
625	if v, ok := page.(util.Cursor); ok {
626		cursor = v.Cursor()
627		// Hide the cursor if it's positioned outside the textarea
628		statusHeight := a.height - strings.Count(pageView, "\n") + 1
629		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
630			cursor = nil
631		}
632	}
633	activeView := a.dialog.ActiveModel()
634	if activeView != nil {
635		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
636		if v, ok := activeView.(util.Cursor); ok {
637			cursor = v.Cursor()
638		}
639	}
640
641	if a.completions.Open() && cursor != nil {
642		cmp := a.completions.View()
643		x, y := a.completions.Position()
644		layers = append(
645			layers,
646			lipgloss.NewLayer(cmp).X(x).Y(y),
647		)
648	}
649
650	canvas := lipgloss.NewCanvas(
651		layers...,
652	)
653
654	view.Content = canvas
655	view.Cursor = cursor
656
657	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
658		// HACK: use a random percentage to prevent ghostty from hiding it
659		// after a timeout.
660		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
661	}
662	return view
663}
664
665func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
666	return func() tea.Msg {
667		a.app.UpdateAgentModel(ctx)
668		return nil
669	}
670}
671
672func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
673	return func() tea.Msg {
674		mcp.RefreshPrompts(ctx, name)
675		return nil
676	}
677}
678
679func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
680	return func() tea.Msg {
681		mcp.RefreshTools(ctx, name)
682		return nil
683	}
684}
685
686// New creates and initializes a new TUI application model.
687func New(app *app.App) *appModel {
688	chatPage := chat.New(app)
689	keyMap := DefaultKeyMap()
690	keyMap.pageBindings = chatPage.Bindings()
691
692	model := &appModel{
693		currentPage: chat.ChatPageID,
694		app:         app,
695		status:      status.NewStatusCmp(),
696		loadedPages: make(map[page.PageID]bool),
697		keyMap:      keyMap,
698
699		pages: map[page.PageID]util.Model{
700			chat.ChatPageID: chatPage,
701		},
702
703		dialog:      dialogs.NewDialogCmp(),
704		completions: completions.New(),
705	}
706
707	return model
708}