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