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