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