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}