tui.go

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