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}