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		s, statusCmd := a.status.Update(util.InfoMsg{
380			Type: util.InfoTypeInfo,
381			Msg:  statusMsg,
382			TTL:  30 * time.Second,
383		})
384		a.status = s.(status.StatusCmp)
385		return a, statusCmd
386	}
387	s, _ := a.status.Update(msg)
388	a.status = s.(status.StatusCmp)
389
390	item, ok := a.pages[a.currentPage]
391	if !ok {
392		return a, nil
393	}
394
395	updated, cmd := item.Update(msg)
396	a.pages[a.currentPage] = updated
397
398	if a.dialog.HasDialogs() {
399		u, dialogCmd := a.dialog.Update(msg)
400		if model, ok := u.(dialogs.DialogCmp); ok {
401			a.dialog = model
402		}
403
404		cmds = append(cmds, dialogCmd)
405	}
406	cmds = append(cmds, cmd)
407	return a, tea.Batch(cmds...)
408}
409
410// handleWindowResize processes window resize events and updates all components.
411func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
412	var cmds []tea.Cmd
413
414	// TODO: clean up these magic numbers.
415	if a.showingFullHelp {
416		height -= 5
417	} else {
418		height -= 2
419	}
420
421	a.width, a.height = width, height
422	// Update status bar
423	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
424	if model, ok := s.(status.StatusCmp); ok {
425		a.status = model
426	}
427	cmds = append(cmds, cmd)
428
429	// Update the current view.
430	for p, page := range a.pages {
431		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
432		a.pages[p] = updated
433
434		cmds = append(cmds, pageCmd)
435	}
436
437	// Update the dialogs
438	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
439	if model, ok := dialog.(dialogs.DialogCmp); ok {
440		a.dialog = model
441	}
442
443	cmds = append(cmds, cmd)
444
445	return tea.Batch(cmds...)
446}
447
448// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
449func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
450	// Check this first as the user should be able to quit no matter what.
451	if key.Matches(msg, a.keyMap.Quit) {
452		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
453			return tea.Quit
454		}
455		return util.CmdHandler(dialogs.OpenDialogMsg{
456			Model: quit.NewQuitDialog(),
457		})
458	}
459
460	if a.completions.Open() {
461		// completions
462		keyMap := a.completions.KeyMap()
463		switch {
464		case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
465			key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
466			key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
467			u, cmd := a.completions.Update(msg)
468			a.completions = u.(completions.Completions)
469			return cmd
470		}
471	}
472	if a.dialog.HasDialogs() {
473		u, dialogCmd := a.dialog.Update(msg)
474		a.dialog = u.(dialogs.DialogCmp)
475		return dialogCmd
476	}
477	switch {
478	// help
479	case key.Matches(msg, a.keyMap.Help):
480		a.status.ToggleFullHelp()
481		a.showingFullHelp = !a.showingFullHelp
482		return a.handleWindowResize(a.wWidth, a.wHeight)
483	// dialogs
484	case key.Matches(msg, a.keyMap.Commands):
485		// if the app is not configured show no commands
486		if !a.isConfigured {
487			return nil
488		}
489		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
490			return util.CmdHandler(dialogs.CloseDialogMsg{})
491		}
492		if a.dialog.HasDialogs() {
493			return nil
494		}
495		return util.CmdHandler(dialogs.OpenDialogMsg{
496			Model: commands.NewCommandDialog(a.selectedSessionID),
497		})
498	case key.Matches(msg, a.keyMap.Models):
499		// if the app is not configured show no models
500		if !a.isConfigured {
501			return nil
502		}
503		if a.dialog.ActiveDialogID() == models.ModelsDialogID {
504			return util.CmdHandler(dialogs.CloseDialogMsg{})
505		}
506		if a.dialog.HasDialogs() {
507			return nil
508		}
509		return util.CmdHandler(dialogs.OpenDialogMsg{
510			Model: models.NewModelDialogCmp(),
511		})
512	case key.Matches(msg, a.keyMap.Sessions):
513		// if the app is not configured show no sessions
514		if !a.isConfigured {
515			return nil
516		}
517		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
518			return util.CmdHandler(dialogs.CloseDialogMsg{})
519		}
520		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
521			return nil
522		}
523		var cmds []tea.Cmd
524		cmds = append(cmds,
525			func() tea.Msg {
526				allSessions, _ := a.app.Sessions.List(context.Background())
527				return dialogs.OpenDialogMsg{
528					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
529				}
530			},
531		)
532		return tea.Sequence(cmds...)
533	case key.Matches(msg, a.keyMap.Suspend):
534		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
535			return util.ReportWarn("Agent is busy, please wait...")
536		}
537		return tea.Suspend
538	default:
539		item, ok := a.pages[a.currentPage]
540		if !ok {
541			return nil
542		}
543
544		updated, cmd := item.Update(msg)
545		a.pages[a.currentPage] = updated
546		return cmd
547	}
548}
549
550// moveToPage handles navigation between different pages in the application.
551func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
552	if a.app.AgentCoordinator.IsBusy() {
553		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
554		return util.ReportWarn("Agent is busy, please wait...")
555	}
556
557	var cmds []tea.Cmd
558	if _, ok := a.loadedPages[pageID]; !ok {
559		cmd := a.pages[pageID].Init()
560		cmds = append(cmds, cmd)
561		a.loadedPages[pageID] = true
562	}
563	a.previousPage = a.currentPage
564	a.currentPage = pageID
565	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
566		cmd := sizable.SetSize(a.width, a.height)
567		cmds = append(cmds, cmd)
568	}
569
570	return tea.Batch(cmds...)
571}
572
573// View renders the complete application interface including pages, dialogs, and overlays.
574func (a *appModel) View() tea.View {
575	var view tea.View
576	t := styles.CurrentTheme()
577	view.AltScreen = true
578	view.MouseMode = tea.MouseModeCellMotion
579	view.BackgroundColor = t.BgBase
580	if a.wWidth < 25 || a.wHeight < 15 {
581		view.SetContent(
582			lipgloss.NewCanvas(
583				lipgloss.NewLayer(
584					t.S().Base.Width(a.wWidth).Height(a.wHeight).
585						Align(lipgloss.Center, lipgloss.Center).
586						Render(
587							t.S().Base.
588								Padding(1, 4).
589								Foreground(t.White).
590								BorderStyle(lipgloss.RoundedBorder()).
591								BorderForeground(t.Primary).
592								Render("Window too small!"),
593						),
594				),
595			),
596		)
597		return view
598	}
599
600	page := a.pages[a.currentPage]
601	if withHelp, ok := page.(core.KeyMapHelp); ok {
602		a.status.SetKeyMap(withHelp.Help())
603	}
604	pageView := page.View()
605	components := []string{
606		pageView,
607	}
608	components = append(components, a.status.View())
609
610	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
611	layers := []*lipgloss.Layer{
612		lipgloss.NewLayer(appView),
613	}
614	if a.dialog.HasDialogs() {
615		layers = append(
616			layers,
617			a.dialog.GetLayers()...,
618		)
619	}
620
621	var cursor *tea.Cursor
622	if v, ok := page.(util.Cursor); ok {
623		cursor = v.Cursor()
624		// Hide the cursor if it's positioned outside the textarea
625		statusHeight := a.height - strings.Count(pageView, "\n") + 1
626		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
627			cursor = nil
628		}
629	}
630	activeView := a.dialog.ActiveModel()
631	if activeView != nil {
632		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
633		if v, ok := activeView.(util.Cursor); ok {
634			cursor = v.Cursor()
635		}
636	}
637
638	if a.completions.Open() && cursor != nil {
639		cmp := a.completions.View()
640		x, y := a.completions.Position()
641		layers = append(
642			layers,
643			lipgloss.NewLayer(cmp).X(x).Y(y),
644		)
645	}
646
647	canvas := lipgloss.NewCanvas(
648		layers...,
649	)
650
651	view.Content = canvas
652	view.Cursor = cursor
653
654	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
655		// HACK: use a random percentage to prevent ghostty from hiding it
656		// after a timeout.
657		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
658	}
659	return view
660}
661
662func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
663	return func() tea.Msg {
664		a.app.UpdateAgentModel(ctx)
665		return nil
666	}
667}
668
669func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
670	return func() tea.Msg {
671		mcp.RefreshPrompts(ctx, name)
672		return nil
673	}
674}
675
676func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
677	return func() tea.Msg {
678		mcp.RefreshTools(ctx, name)
679		return nil
680	}
681}
682
683// New creates and initializes a new TUI application model.
684func New(app *app.App) *appModel {
685	chatPage := chat.New(app)
686	keyMap := DefaultKeyMap()
687	keyMap.pageBindings = chatPage.Bindings()
688
689	model := &appModel{
690		currentPage: chat.ChatPageID,
691		app:         app,
692		status:      status.NewStatusCmp(),
693		loadedPages: make(map[page.PageID]bool),
694		keyMap:      keyMap,
695
696		pages: map[page.PageID]util.Model{
697			chat.ChatPageID: chatPage,
698		},
699
700		dialog:      dialogs.NewDialogCmp(),
701		completions: completions.New(),
702	}
703
704	return model
705}