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