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