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/notification"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
23 "github.com/charmbracelet/crush/internal/tui/components/completions"
24 "github.com/charmbracelet/crush/internal/tui/components/core"
25 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/core/status"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
34 "github.com/charmbracelet/crush/internal/tui/page"
35 "github.com/charmbracelet/crush/internal/tui/page/chat"
36 "github.com/charmbracelet/crush/internal/tui/styles"
37 "github.com/charmbracelet/crush/internal/tui/util"
38 "github.com/charmbracelet/x/ansi"
39 "golang.org/x/text/cases"
40 "golang.org/x/text/language"
41)
42
43var lastMouseEvent time.Time
44
45func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
46 switch msg.(type) {
47 case tea.MouseWheelMsg, tea.MouseMotionMsg:
48 now := time.Now()
49 // trackpad is sending too many requests
50 if now.Sub(lastMouseEvent) < 15*time.Millisecond {
51 return nil
52 }
53 lastMouseEvent = now
54 }
55 return msg
56}
57
58// appModel represents the main application model that manages pages, dialogs, and UI state.
59type appModel struct {
60 wWidth, wHeight int // Window dimensions
61 width, height int
62 keyMap KeyMap
63
64 currentPage page.PageID
65 previousPage page.PageID
66 pages map[page.PageID]util.Model
67 loadedPages map[page.PageID]bool
68
69 // Status
70 status status.StatusCmp
71 showingFullHelp bool
72
73 app *app.App
74
75 dialog dialogs.DialogCmp
76 completions completions.Completions
77 isConfigured bool
78
79 // Chat Page Specific
80 selectedSessionID string // The ID of the currently selected session
81
82 // sendProgressBar instructs the TUI to send progress bar updates to the
83 // terminal.
84 sendProgressBar bool
85
86 // QueryVersion instructs the TUI to query for the terminal version when it
87 // starts.
88 QueryVersion bool
89
90 // Focus tracking for notifications.
91 supportsFocus bool
92 isFocused bool
93}
94
95// Init initializes the application model and returns initial commands.
96func (a appModel) Init() tea.Cmd {
97 item, ok := a.pages[a.currentPage]
98 if !ok {
99 return nil
100 }
101
102 var cmds []tea.Cmd
103 cmd := item.Init()
104 cmds = append(cmds, cmd)
105 a.loadedPages[a.currentPage] = true
106
107 cmd = a.status.Init()
108 cmds = append(cmds, cmd)
109 if a.QueryVersion {
110 cmds = append(cmds, tea.RequestTerminalVersion)
111 }
112
113 // Request focus event support from the terminal.
114 cmds = append(cmds, tea.Raw(ansi.RequestModeFocusEvent))
115
116 return tea.Batch(cmds...)
117}
118
119// Update handles incoming messages and updates the application state.
120func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121 var cmds []tea.Cmd
122 var cmd tea.Cmd
123 a.isConfigured = config.HasInitialDataConfig()
124
125 switch msg := msg.(type) {
126 case tea.ModeReportMsg:
127 if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
128 a.supportsFocus = true
129 a.isFocused = true
130 notification.SetFocusSupport(true)
131 notification.SetFocused(true)
132 }
133 return a, nil
134 case tea.FocusMsg:
135 a.isFocused = true
136 notification.SetFocused(true)
137 return a, nil
138 case tea.BlurMsg:
139 a.isFocused = false
140 notification.SetFocused(false)
141 return a, nil
142 case tea.EnvMsg:
143 // Is this Windows Terminal?
144 if !a.sendProgressBar {
145 a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
146 }
147 case tea.TerminalVersionMsg:
148 termVersion := strings.ToLower(msg.Name)
149 // Only enable progress bar for the following terminals.
150 if !a.sendProgressBar {
151 a.sendProgressBar = strings.Contains(termVersion, "ghostty")
152 }
153 return a, nil
154 case tea.KeyboardEnhancementsMsg:
155 // A non-zero value means we have key disambiguation support.
156 if msg.Flags > 0 {
157 a.keyMap.Models.SetHelp("ctrl+m", "models")
158 }
159 for id, page := range a.pages {
160 m, pageCmd := page.Update(msg)
161 a.pages[id] = m
162
163 if pageCmd != nil {
164 cmds = append(cmds, pageCmd)
165 }
166 }
167 return a, tea.Batch(cmds...)
168 case tea.WindowSizeMsg:
169 a.wWidth, a.wHeight = msg.Width, msg.Height
170 a.completions.Update(msg)
171 return a, a.handleWindowResize(msg.Width, msg.Height)
172
173 case pubsub.Event[mcp.Event]:
174 switch msg.Payload.Type {
175 case mcp.EventStateChanged:
176 return a, a.handleStateChanged(context.Background())
177 case mcp.EventPromptsListChanged:
178 return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
179 case mcp.EventToolsListChanged:
180 return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
181 }
182
183 // Completions messages
184 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
185 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
186 u, completionCmd := a.completions.Update(msg)
187 if model, ok := u.(completions.Completions); ok {
188 a.completions = model
189 }
190
191 return a, completionCmd
192
193 // Dialog messages
194 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
195 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
196 a.completions = u.(completions.Completions)
197 u, dialogCmd := a.dialog.Update(msg)
198 a.dialog = u.(dialogs.DialogCmp)
199 return a, tea.Batch(completionCmd, dialogCmd)
200 case commands.ShowArgumentsDialogMsg:
201 var args []commands.Argument
202 for _, arg := range msg.ArgNames {
203 args = append(args, commands.Argument{
204 Name: arg,
205 Title: cases.Title(language.English).String(arg),
206 Required: true,
207 })
208 }
209 return a, util.CmdHandler(
210 dialogs.OpenDialogMsg{
211 Model: commands.NewCommandArgumentsDialog(
212 msg.CommandID,
213 msg.CommandID,
214 msg.CommandID,
215 msg.Description,
216 args,
217 msg.OnSubmit,
218 ),
219 },
220 )
221 case commands.ShowMCPPromptArgumentsDialogMsg:
222 args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
223 for _, arg := range msg.Prompt.Arguments {
224 args = append(args, commands.Argument(*arg))
225 }
226 dialog := commands.NewCommandArgumentsDialog(
227 msg.Prompt.Name,
228 msg.Prompt.Title,
229 msg.Prompt.Name,
230 msg.Prompt.Description,
231 args,
232 msg.OnSubmit,
233 )
234 return a, util.CmdHandler(
235 dialogs.OpenDialogMsg{
236 Model: dialog,
237 },
238 )
239 // Page change messages
240 case page.PageChangeMsg:
241 return a, a.moveToPage(msg.ID)
242
243 // Status Messages
244 case util.InfoMsg, util.ClearStatusMsg:
245 s, statusCmd := a.status.Update(msg)
246 a.status = s.(status.StatusCmp)
247 cmds = append(cmds, statusCmd)
248 return a, tea.Batch(cmds...)
249
250 // Session
251 case cmpChat.SessionSelectedMsg:
252 a.selectedSessionID = msg.ID
253 case cmpChat.SessionClearedMsg:
254 a.selectedSessionID = ""
255 // Commands
256 case commands.SwitchSessionsMsg:
257 return a, func() tea.Msg {
258 allSessions, _ := a.app.Sessions.List(context.Background())
259 return dialogs.OpenDialogMsg{
260 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
261 }
262 }
263
264 case commands.SwitchModelMsg:
265 return a, util.CmdHandler(
266 dialogs.OpenDialogMsg{
267 Model: models.NewModelDialogCmp(),
268 },
269 )
270 // Compact
271 case commands.CompactMsg:
272 return a, func() tea.Msg {
273 err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
274 if err != nil {
275 return util.ReportError(err)()
276 }
277 return nil
278 }
279 case commands.QuitMsg:
280 return a, util.CmdHandler(dialogs.OpenDialogMsg{
281 Model: quit.NewQuitDialog(),
282 })
283 case commands.ToggleYoloModeMsg:
284 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
285 case commands.ToggleHelpMsg:
286 a.status.ToggleFullHelp()
287 a.showingFullHelp = !a.showingFullHelp
288 return a, a.handleWindowResize(a.wWidth, a.wHeight)
289 // Model Switch
290 case models.ModelSelectedMsg:
291 if a.app.AgentCoordinator.IsBusy() {
292 return a, util.ReportWarn("Agent is busy, please wait...")
293 }
294
295 cfg := config.Get()
296 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
297 return a, util.ReportError(err)
298 }
299
300 go a.app.UpdateAgentModel(context.TODO())
301
302 modelTypeName := "large"
303 if msg.ModelType == config.SelectedModelTypeSmall {
304 modelTypeName = "small"
305 }
306 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
307
308 // File Picker
309 case commands.OpenFilePickerMsg:
310 event.FilePickerOpened()
311
312 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
313 // If the commands dialog is already open, close it
314 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
315 }
316 return a, util.CmdHandler(dialogs.OpenDialogMsg{
317 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
318 })
319 // Permissions
320 case pubsub.Event[permission.PermissionNotification]:
321 item, ok := a.pages[a.currentPage]
322 if !ok {
323 return a, nil
324 }
325
326 // Forward to view.
327 updated, itemCmd := item.Update(msg)
328 a.pages[a.currentPage] = updated
329
330 return a, itemCmd
331 case pubsub.Event[permission.PermissionRequest]:
332 // Send notification if window is not focused.
333 notifBody := fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName)
334 if err := notification.Send("Crush is waiting...", notifBody); err != nil {
335 // Log but don't fail on notification errors.
336 }
337 return a, util.CmdHandler(dialogs.OpenDialogMsg{
338 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
339 DiffMode: config.Get().Options.TUI.DiffMode,
340 }),
341 })
342 case permissions.PermissionResponseMsg:
343 switch msg.Action {
344 case permissions.PermissionAllow:
345 a.app.Permissions.Grant(msg.Permission)
346 case permissions.PermissionAllowForSession:
347 a.app.Permissions.GrantPersistent(msg.Permission)
348 case permissions.PermissionDeny:
349 a.app.Permissions.Deny(msg.Permission)
350 }
351 return a, nil
352 case splash.OnboardingCompleteMsg:
353 item, ok := a.pages[a.currentPage]
354 if !ok {
355 return a, nil
356 }
357
358 a.isConfigured = config.HasInitialDataConfig()
359 updated, pageCmd := item.Update(msg)
360 a.pages[a.currentPage] = updated
361
362 cmds = append(cmds, pageCmd)
363 return a, tea.Batch(cmds...)
364
365 case tea.KeyPressMsg:
366 return a, a.handleKeyPressMsg(msg)
367
368 case tea.MouseWheelMsg:
369 if a.dialog.HasDialogs() {
370 u, dialogCmd := a.dialog.Update(msg)
371 a.dialog = u.(dialogs.DialogCmp)
372 cmds = append(cmds, dialogCmd)
373 } else {
374 item, ok := a.pages[a.currentPage]
375 if !ok {
376 return a, nil
377 }
378
379 updated, pageCmd := item.Update(msg)
380 a.pages[a.currentPage] = updated
381
382 cmds = append(cmds, pageCmd)
383 }
384 return a, tea.Batch(cmds...)
385 case tea.PasteMsg:
386 if a.dialog.HasDialogs() {
387 u, dialogCmd := a.dialog.Update(msg)
388 if model, ok := u.(dialogs.DialogCmp); ok {
389 a.dialog = model
390 }
391
392 cmds = append(cmds, dialogCmd)
393 } else {
394 item, ok := a.pages[a.currentPage]
395 if !ok {
396 return a, nil
397 }
398
399 updated, pageCmd := item.Update(msg)
400 a.pages[a.currentPage] = updated
401
402 cmds = append(cmds, pageCmd)
403 }
404 return a, tea.Batch(cmds...)
405 // Update Available
406 case pubsub.UpdateAvailableMsg:
407 // Show update notification in status bar
408 statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
409 if msg.IsDevelopment {
410 statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
411 }
412 s, statusCmd := a.status.Update(util.InfoMsg{
413 Type: util.InfoTypeUpdate,
414 Msg: statusMsg,
415 TTL: 10 * time.Second,
416 })
417 a.status = s.(status.StatusCmp)
418 return a, statusCmd
419 }
420 s, _ := a.status.Update(msg)
421 a.status = s.(status.StatusCmp)
422
423 item, ok := a.pages[a.currentPage]
424 if !ok {
425 return a, nil
426 }
427
428 updated, cmd := item.Update(msg)
429 a.pages[a.currentPage] = updated
430
431 if a.dialog.HasDialogs() {
432 u, dialogCmd := a.dialog.Update(msg)
433 if model, ok := u.(dialogs.DialogCmp); ok {
434 a.dialog = model
435 }
436
437 cmds = append(cmds, dialogCmd)
438 }
439 cmds = append(cmds, cmd)
440 return a, tea.Batch(cmds...)
441}
442
443// handleWindowResize processes window resize events and updates all components.
444func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
445 var cmds []tea.Cmd
446
447 // TODO: clean up these magic numbers.
448 if a.showingFullHelp {
449 height -= 5
450 } else {
451 height -= 2
452 }
453
454 a.width, a.height = width, height
455 // Update status bar
456 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
457 if model, ok := s.(status.StatusCmp); ok {
458 a.status = model
459 }
460 cmds = append(cmds, cmd)
461
462 // Update the current view.
463 for p, page := range a.pages {
464 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
465 a.pages[p] = updated
466
467 cmds = append(cmds, pageCmd)
468 }
469
470 // Update the dialogs
471 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
472 if model, ok := dialog.(dialogs.DialogCmp); ok {
473 a.dialog = model
474 }
475
476 cmds = append(cmds, cmd)
477
478 return tea.Batch(cmds...)
479}
480
481// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
482func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
483 // Check this first as the user should be able to quit no matter what.
484 if key.Matches(msg, a.keyMap.Quit) {
485 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
486 return tea.Quit
487 }
488 return util.CmdHandler(dialogs.OpenDialogMsg{
489 Model: quit.NewQuitDialog(),
490 })
491 }
492
493 if a.completions.Open() {
494 // completions
495 keyMap := a.completions.KeyMap()
496 switch {
497 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
498 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
499 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
500 u, cmd := a.completions.Update(msg)
501 a.completions = u.(completions.Completions)
502 return cmd
503 }
504 }
505 if a.dialog.HasDialogs() {
506 u, dialogCmd := a.dialog.Update(msg)
507 a.dialog = u.(dialogs.DialogCmp)
508 return dialogCmd
509 }
510 switch {
511 // help
512 case key.Matches(msg, a.keyMap.Help):
513 a.status.ToggleFullHelp()
514 a.showingFullHelp = !a.showingFullHelp
515 return a.handleWindowResize(a.wWidth, a.wHeight)
516 // dialogs
517 case key.Matches(msg, a.keyMap.Commands):
518 // if the app is not configured show no commands
519 if !a.isConfigured {
520 return nil
521 }
522 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
523 return util.CmdHandler(dialogs.CloseDialogMsg{})
524 }
525 if a.dialog.HasDialogs() {
526 return nil
527 }
528 return util.CmdHandler(dialogs.OpenDialogMsg{
529 Model: commands.NewCommandDialog(a.selectedSessionID),
530 })
531 case key.Matches(msg, a.keyMap.Models):
532 // if the app is not configured show no models
533 if !a.isConfigured {
534 return nil
535 }
536 if a.dialog.ActiveDialogID() == models.ModelsDialogID {
537 return util.CmdHandler(dialogs.CloseDialogMsg{})
538 }
539 if a.dialog.HasDialogs() {
540 return nil
541 }
542 return util.CmdHandler(dialogs.OpenDialogMsg{
543 Model: models.NewModelDialogCmp(),
544 })
545 case key.Matches(msg, a.keyMap.Sessions):
546 // if the app is not configured show no sessions
547 if !a.isConfigured {
548 return nil
549 }
550 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
551 return util.CmdHandler(dialogs.CloseDialogMsg{})
552 }
553 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
554 return nil
555 }
556 var cmds []tea.Cmd
557 cmds = append(cmds,
558 func() tea.Msg {
559 allSessions, _ := a.app.Sessions.List(context.Background())
560 return dialogs.OpenDialogMsg{
561 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
562 }
563 },
564 )
565 return tea.Sequence(cmds...)
566 case key.Matches(msg, a.keyMap.Suspend):
567 if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
568 return util.ReportWarn("Agent is busy, please wait...")
569 }
570 return tea.Suspend
571 default:
572 item, ok := a.pages[a.currentPage]
573 if !ok {
574 return nil
575 }
576
577 updated, cmd := item.Update(msg)
578 a.pages[a.currentPage] = updated
579 return cmd
580 }
581}
582
583// moveToPage handles navigation between different pages in the application.
584func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
585 if a.app.AgentCoordinator.IsBusy() {
586 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
587 return util.ReportWarn("Agent is busy, please wait...")
588 }
589
590 var cmds []tea.Cmd
591 if _, ok := a.loadedPages[pageID]; !ok {
592 cmd := a.pages[pageID].Init()
593 cmds = append(cmds, cmd)
594 a.loadedPages[pageID] = true
595 }
596 a.previousPage = a.currentPage
597 a.currentPage = pageID
598 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
599 cmd := sizable.SetSize(a.width, a.height)
600 cmds = append(cmds, cmd)
601 }
602
603 return tea.Batch(cmds...)
604}
605
606// View renders the complete application interface including pages, dialogs, and overlays.
607func (a *appModel) View() tea.View {
608 var view tea.View
609 t := styles.CurrentTheme()
610 view.AltScreen = true
611 view.MouseMode = tea.MouseModeCellMotion
612 view.BackgroundColor = t.BgBase
613 view.ReportFocus = true
614 if a.wWidth < 25 || a.wHeight < 15 {
615 view.SetContent(
616 lipgloss.NewCanvas(
617 lipgloss.NewLayer(
618 t.S().Base.Width(a.wWidth).Height(a.wHeight).
619 Align(lipgloss.Center, lipgloss.Center).
620 Render(
621 t.S().Base.
622 Padding(1, 4).
623 Foreground(t.White).
624 BorderStyle(lipgloss.RoundedBorder()).
625 BorderForeground(t.Primary).
626 Render("Window too small!"),
627 ),
628 ),
629 ),
630 )
631 return view
632 }
633
634 page := a.pages[a.currentPage]
635 if withHelp, ok := page.(core.KeyMapHelp); ok {
636 a.status.SetKeyMap(withHelp.Help())
637 }
638 pageView := page.View()
639 components := []string{
640 pageView,
641 }
642 components = append(components, a.status.View())
643
644 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
645 layers := []*lipgloss.Layer{
646 lipgloss.NewLayer(appView),
647 }
648 if a.dialog.HasDialogs() {
649 layers = append(
650 layers,
651 a.dialog.GetLayers()...,
652 )
653 }
654
655 var cursor *tea.Cursor
656 if v, ok := page.(util.Cursor); ok {
657 cursor = v.Cursor()
658 // Hide the cursor if it's positioned outside the textarea
659 statusHeight := a.height - strings.Count(pageView, "\n") + 1
660 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
661 cursor = nil
662 }
663 }
664 activeView := a.dialog.ActiveModel()
665 if activeView != nil {
666 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
667 if v, ok := activeView.(util.Cursor); ok {
668 cursor = v.Cursor()
669 }
670 }
671
672 if a.completions.Open() && cursor != nil {
673 cmp := a.completions.View()
674 x, y := a.completions.Position()
675 layers = append(
676 layers,
677 lipgloss.NewLayer(cmp).X(x).Y(y),
678 )
679 }
680
681 canvas := lipgloss.NewCanvas(
682 layers...,
683 )
684
685 view.Content = canvas
686 view.Cursor = cursor
687
688 if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
689 // HACK: use a random percentage to prevent ghostty from hiding it
690 // after a timeout.
691 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
692 }
693 return view
694}
695
696func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
697 return func() tea.Msg {
698 a.app.UpdateAgentModel(ctx)
699 return nil
700 }
701}
702
703func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
704 return func() tea.Msg {
705 mcp.RefreshPrompts(ctx, name)
706 return nil
707 }
708}
709
710func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
711 return func() tea.Msg {
712 mcp.RefreshTools(ctx, name)
713 return nil
714 }
715}
716
717// New creates and initializes a new TUI application model.
718func New(app *app.App) *appModel {
719 chatPage := chat.New(app)
720 keyMap := DefaultKeyMap()
721 keyMap.pageBindings = chatPage.Bindings()
722
723 model := &appModel{
724 currentPage: chat.ChatPageID,
725 app: app,
726 status: status.NewStatusCmp(),
727 loadedPages: make(map[page.PageID]bool),
728 keyMap: keyMap,
729
730 pages: map[page.PageID]util.Model{
731 chat.ChatPageID: chatPage,
732 },
733
734 dialog: dialogs.NewDialogCmp(),
735 completions: completions.New(),
736 }
737
738 return model
739}