tui.go

  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}