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		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}