1package tui
2
3import (
4 "context"
5 "fmt"
6 "math/rand"
7 "slices"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/bubbles/v2/key"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14 "github.com/charmbracelet/crush/internal/app"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/event"
17 "github.com/charmbracelet/crush/internal/permission"
18 "github.com/charmbracelet/crush/internal/pubsub"
19 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
21 "github.com/charmbracelet/crush/internal/tui/components/completions"
22 "github.com/charmbracelet/crush/internal/tui/components/core"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/core/status"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
32 "github.com/charmbracelet/crush/internal/tui/page"
33 "github.com/charmbracelet/crush/internal/tui/page/chat"
34 "github.com/charmbracelet/crush/internal/tui/styles"
35 "github.com/charmbracelet/crush/internal/tui/util"
36 "github.com/charmbracelet/lipgloss/v2"
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(string(msg))
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 for id, page := range a.pages {
131 m, pageCmd := page.Update(msg)
132 a.pages[id] = m
133
134 if pageCmd != nil {
135 cmds = append(cmds, pageCmd)
136 }
137 }
138 return a, tea.Batch(cmds...)
139 case tea.WindowSizeMsg:
140 a.wWidth, a.wHeight = msg.Width, msg.Height
141 a.completions.Update(msg)
142 return a, a.handleWindowResize(msg.Width, msg.Height)
143
144 case pubsub.Event[mcp.Event]:
145 switch msg.Payload.Type {
146 case mcp.EventPromptsListChanged:
147
148 case mcp.EventToolsListChanged:
149 return a, a.handleMCPToolsEvent(context.Background(), msg.Payload.Name)
150 }
151
152 // Completions messages
153 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
154 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
155 u, completionCmd := a.completions.Update(msg)
156 if model, ok := u.(completions.Completions); ok {
157 a.completions = model
158 }
159
160 return a, completionCmd
161
162 // Dialog messages
163 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
164 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
165 a.completions = u.(completions.Completions)
166 u, dialogCmd := a.dialog.Update(msg)
167 a.dialog = u.(dialogs.DialogCmp)
168 return a, tea.Batch(completionCmd, dialogCmd)
169 case commands.ShowArgumentsDialogMsg:
170 var args []commands.Argument
171 for _, arg := range msg.ArgNames {
172 args = append(args, commands.Argument{
173 Name: arg,
174 Title: cases.Title(language.English).String(arg),
175 Required: true,
176 })
177 }
178 return a, util.CmdHandler(
179 dialogs.OpenDialogMsg{
180 Model: commands.NewCommandArgumentsDialog(
181 msg.CommandID,
182 msg.CommandID,
183 msg.CommandID,
184 msg.Description,
185 args,
186 msg.OnSubmit,
187 ),
188 },
189 )
190 case commands.ShowMCPPromptArgumentsDialogMsg:
191 args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
192 for _, arg := range msg.Prompt.Arguments {
193 args = append(args, commands.Argument(*arg))
194 }
195 dialog := commands.NewCommandArgumentsDialog(
196 msg.Prompt.Name,
197 msg.Prompt.Title,
198 msg.Prompt.Name,
199 msg.Prompt.Description,
200 args,
201 msg.OnSubmit,
202 )
203 return a, util.CmdHandler(
204 dialogs.OpenDialogMsg{
205 Model: dialog,
206 },
207 )
208 // Page change messages
209 case page.PageChangeMsg:
210 return a, a.moveToPage(msg.ID)
211
212 // Status Messages
213 case util.InfoMsg, util.ClearStatusMsg:
214 s, statusCmd := a.status.Update(msg)
215 a.status = s.(status.StatusCmp)
216 cmds = append(cmds, statusCmd)
217 return a, tea.Batch(cmds...)
218
219 // Session
220 case cmpChat.SessionSelectedMsg:
221 a.selectedSessionID = msg.ID
222 case cmpChat.SessionClearedMsg:
223 a.selectedSessionID = ""
224 // Commands
225 case commands.SwitchSessionsMsg:
226 return a, func() tea.Msg {
227 allSessions, _ := a.app.Sessions.List(context.Background())
228 return dialogs.OpenDialogMsg{
229 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
230 }
231 }
232
233 case commands.SwitchModelMsg:
234 return a, util.CmdHandler(
235 dialogs.OpenDialogMsg{
236 Model: models.NewModelDialogCmp(),
237 },
238 )
239 // Compact
240 case commands.CompactMsg:
241 return a, func() tea.Msg {
242 err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
243 if err != nil {
244 return util.ReportError(err)()
245 }
246 return nil
247 }
248 case commands.QuitMsg:
249 return a, util.CmdHandler(dialogs.OpenDialogMsg{
250 Model: quit.NewQuitDialog(),
251 })
252 case commands.ToggleYoloModeMsg:
253 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
254 case commands.ToggleHelpMsg:
255 a.status.ToggleFullHelp()
256 a.showingFullHelp = !a.showingFullHelp
257 return a, a.handleWindowResize(a.wWidth, a.wHeight)
258 // Model Switch
259 case models.ModelSelectedMsg:
260 if a.app.AgentCoordinator.IsBusy() {
261 return a, util.ReportWarn("Agent is busy, please wait...")
262 }
263
264 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
265
266 go a.app.UpdateAgentModel(context.TODO())
267
268 modelTypeName := "large"
269 if msg.ModelType == config.SelectedModelTypeSmall {
270 modelTypeName = "small"
271 }
272 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
273
274 // File Picker
275 case commands.OpenFilePickerMsg:
276 event.FilePickerOpened()
277
278 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
279 // If the commands dialog is already open, close it
280 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
281 }
282 return a, util.CmdHandler(dialogs.OpenDialogMsg{
283 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
284 })
285 // Permissions
286 case pubsub.Event[permission.PermissionNotification]:
287 item, ok := a.pages[a.currentPage]
288 if !ok {
289 return a, nil
290 }
291
292 // Forward to view.
293 updated, itemCmd := item.Update(msg)
294 a.pages[a.currentPage] = updated
295
296 return a, itemCmd
297 case pubsub.Event[permission.PermissionRequest]:
298 return a, util.CmdHandler(dialogs.OpenDialogMsg{
299 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
300 DiffMode: config.Get().Options.TUI.DiffMode,
301 }),
302 })
303 case permissions.PermissionResponseMsg:
304 switch msg.Action {
305 case permissions.PermissionAllow:
306 a.app.Permissions.Grant(msg.Permission)
307 case permissions.PermissionAllowForSession:
308 a.app.Permissions.GrantPersistent(msg.Permission)
309 case permissions.PermissionDeny:
310 a.app.Permissions.Deny(msg.Permission)
311 }
312 return a, nil
313 case splash.OnboardingCompleteMsg:
314 item, ok := a.pages[a.currentPage]
315 if !ok {
316 return a, nil
317 }
318
319 a.isConfigured = config.HasInitialDataConfig()
320 updated, pageCmd := item.Update(msg)
321 a.pages[a.currentPage] = updated
322
323 cmds = append(cmds, pageCmd)
324 return a, tea.Batch(cmds...)
325
326 case tea.KeyPressMsg:
327 return a, a.handleKeyPressMsg(msg)
328
329 case tea.MouseWheelMsg:
330 if a.dialog.HasDialogs() {
331 u, dialogCmd := a.dialog.Update(msg)
332 a.dialog = u.(dialogs.DialogCmp)
333 cmds = append(cmds, dialogCmd)
334 } else {
335 item, ok := a.pages[a.currentPage]
336 if !ok {
337 return a, nil
338 }
339
340 updated, pageCmd := item.Update(msg)
341 a.pages[a.currentPage] = updated
342
343 cmds = append(cmds, pageCmd)
344 }
345 return a, tea.Batch(cmds...)
346 case tea.PasteMsg:
347 if a.dialog.HasDialogs() {
348 u, dialogCmd := a.dialog.Update(msg)
349 if model, ok := u.(dialogs.DialogCmp); ok {
350 a.dialog = model
351 }
352
353 cmds = append(cmds, dialogCmd)
354 } else {
355 item, ok := a.pages[a.currentPage]
356 if !ok {
357 return a, nil
358 }
359
360 updated, pageCmd := item.Update(msg)
361 a.pages[a.currentPage] = updated
362
363 cmds = append(cmds, pageCmd)
364 }
365 return a, tea.Batch(cmds...)
366 }
367 s, _ := a.status.Update(msg)
368 a.status = s.(status.StatusCmp)
369
370 item, ok := a.pages[a.currentPage]
371 if !ok {
372 return a, nil
373 }
374
375 updated, cmd := item.Update(msg)
376 a.pages[a.currentPage] = updated
377
378 if a.dialog.HasDialogs() {
379 u, dialogCmd := a.dialog.Update(msg)
380 if model, ok := u.(dialogs.DialogCmp); ok {
381 a.dialog = model
382 }
383
384 cmds = append(cmds, dialogCmd)
385 }
386 cmds = append(cmds, cmd)
387 return a, tea.Batch(cmds...)
388}
389
390// handleWindowResize processes window resize events and updates all components.
391func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
392 var cmds []tea.Cmd
393
394 // TODO: clean up these magic numbers.
395 if a.showingFullHelp {
396 height -= 5
397 } else {
398 height -= 2
399 }
400
401 a.width, a.height = width, height
402 // Update status bar
403 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
404 if model, ok := s.(status.StatusCmp); ok {
405 a.status = model
406 }
407 cmds = append(cmds, cmd)
408
409 // Update the current view.
410 for p, page := range a.pages {
411 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
412 a.pages[p] = updated
413
414 cmds = append(cmds, pageCmd)
415 }
416
417 // Update the dialogs
418 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
419 if model, ok := dialog.(dialogs.DialogCmp); ok {
420 a.dialog = model
421 }
422
423 cmds = append(cmds, cmd)
424
425 return tea.Batch(cmds...)
426}
427
428// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
429func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
430 // Check this first as the user should be able to quit no matter what.
431 if key.Matches(msg, a.keyMap.Quit) {
432 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
433 return tea.Quit
434 }
435 return util.CmdHandler(dialogs.OpenDialogMsg{
436 Model: quit.NewQuitDialog(),
437 })
438 }
439
440 if a.completions.Open() {
441 // completions
442 keyMap := a.completions.KeyMap()
443 switch {
444 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
445 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
446 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
447 u, cmd := a.completions.Update(msg)
448 a.completions = u.(completions.Completions)
449 return cmd
450 }
451 }
452 if a.dialog.HasDialogs() {
453 u, dialogCmd := a.dialog.Update(msg)
454 a.dialog = u.(dialogs.DialogCmp)
455 return dialogCmd
456 }
457 switch {
458 // help
459 case key.Matches(msg, a.keyMap.Help):
460 a.status.ToggleFullHelp()
461 a.showingFullHelp = !a.showingFullHelp
462 return a.handleWindowResize(a.wWidth, a.wHeight)
463 // dialogs
464 case key.Matches(msg, a.keyMap.Commands):
465 // if the app is not configured show no commands
466 if !a.isConfigured {
467 return nil
468 }
469 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
470 return util.CmdHandler(dialogs.CloseDialogMsg{})
471 }
472 if a.dialog.HasDialogs() {
473 return nil
474 }
475 return util.CmdHandler(dialogs.OpenDialogMsg{
476 Model: commands.NewCommandDialog(a.selectedSessionID),
477 })
478 case key.Matches(msg, a.keyMap.Sessions):
479 // if the app is not configured show no sessions
480 if !a.isConfigured {
481 return nil
482 }
483 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
484 return util.CmdHandler(dialogs.CloseDialogMsg{})
485 }
486 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
487 return nil
488 }
489 var cmds []tea.Cmd
490 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
491 // If the commands dialog is open, close it first
492 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
493 }
494 cmds = append(cmds,
495 func() tea.Msg {
496 allSessions, _ := a.app.Sessions.List(context.Background())
497 return dialogs.OpenDialogMsg{
498 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
499 }
500 },
501 )
502 return tea.Sequence(cmds...)
503 case key.Matches(msg, a.keyMap.Suspend):
504 if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
505 return util.ReportWarn("Agent is busy, please wait...")
506 }
507 return tea.Suspend
508 default:
509 item, ok := a.pages[a.currentPage]
510 if !ok {
511 return nil
512 }
513
514 updated, cmd := item.Update(msg)
515 a.pages[a.currentPage] = updated
516 return cmd
517 }
518}
519
520// moveToPage handles navigation between different pages in the application.
521func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
522 if a.app.AgentCoordinator.IsBusy() {
523 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
524 return util.ReportWarn("Agent is busy, please wait...")
525 }
526
527 var cmds []tea.Cmd
528 if _, ok := a.loadedPages[pageID]; !ok {
529 cmd := a.pages[pageID].Init()
530 cmds = append(cmds, cmd)
531 a.loadedPages[pageID] = true
532 }
533 a.previousPage = a.currentPage
534 a.currentPage = pageID
535 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
536 cmd := sizable.SetSize(a.width, a.height)
537 cmds = append(cmds, cmd)
538 }
539
540 return tea.Batch(cmds...)
541}
542
543// View renders the complete application interface including pages, dialogs, and overlays.
544func (a *appModel) View() tea.View {
545 var view tea.View
546 view.AltScreen = true
547 t := styles.CurrentTheme()
548 view.BackgroundColor = t.BgBase
549 if a.wWidth < 25 || a.wHeight < 15 {
550 view.Layer = lipgloss.NewCanvas(
551 lipgloss.NewLayer(
552 t.S().Base.Width(a.wWidth).Height(a.wHeight).
553 Align(lipgloss.Center, lipgloss.Center).
554 Render(
555 t.S().Base.
556 Padding(1, 4).
557 Foreground(t.White).
558 BorderStyle(lipgloss.RoundedBorder()).
559 BorderForeground(t.Primary).
560 Render("Window too small!"),
561 ),
562 ),
563 )
564 return view
565 }
566
567 page := a.pages[a.currentPage]
568 if withHelp, ok := page.(core.KeyMapHelp); ok {
569 a.status.SetKeyMap(withHelp.Help())
570 }
571 pageView := page.View()
572 components := []string{
573 pageView,
574 }
575 components = append(components, a.status.View())
576
577 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
578 layers := []*lipgloss.Layer{
579 lipgloss.NewLayer(appView),
580 }
581 if a.dialog.HasDialogs() {
582 layers = append(
583 layers,
584 a.dialog.GetLayers()...,
585 )
586 }
587
588 var cursor *tea.Cursor
589 if v, ok := page.(util.Cursor); ok {
590 cursor = v.Cursor()
591 // Hide the cursor if it's positioned outside the textarea
592 statusHeight := a.height - strings.Count(pageView, "\n") + 1
593 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
594 cursor = nil
595 }
596 }
597 activeView := a.dialog.ActiveModel()
598 if activeView != nil {
599 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
600 if v, ok := activeView.(util.Cursor); ok {
601 cursor = v.Cursor()
602 }
603 }
604
605 if a.completions.Open() && cursor != nil {
606 cmp := a.completions.View()
607 x, y := a.completions.Position()
608 layers = append(
609 layers,
610 lipgloss.NewLayer(cmp).X(x).Y(y),
611 )
612 }
613
614 canvas := lipgloss.NewCanvas(
615 layers...,
616 )
617
618 view.Layer = canvas
619 view.Cursor = cursor
620 view.MouseMode = tea.MouseModeCellMotion
621
622 if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
623 // HACK: use a random percentage to prevent ghostty from hiding it
624 // after a timeout.
625 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
626 }
627 return view
628}
629
630func (a *appModel) handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
631 return func() tea.Msg {
632 mcp.RefreshPrompts(ctx, name)
633 return nil
634 }
635}
636
637func (a *appModel) handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
638 return func() tea.Msg {
639 mcp.RefreshTools(ctx, name)
640 return nil
641 }
642}
643
644// New creates and initializes a new TUI application model.
645func New(app *app.App) *appModel {
646 chatPage := chat.New(app)
647 keyMap := DefaultKeyMap()
648 keyMap.pageBindings = chatPage.Bindings()
649
650 model := &appModel{
651 currentPage: chat.ChatPageID,
652 app: app,
653 status: status.NewStatusCmp(),
654 loadedPages: make(map[page.PageID]bool),
655 keyMap: keyMap,
656
657 pages: map[page.PageID]util.Model{
658 chat.ChatPageID: chatPage,
659 },
660
661 dialog: dialogs.NewDialogCmp(),
662 completions: completions.New(),
663 }
664
665 return model
666}