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 splash.OnboardingCompleteMsg:
274		item, ok := a.pages[a.currentPage]
275		if !ok {
276			return a, nil
277		}
278
279		a.isConfigured = config.HasInitialDataConfig()
280		updated, pageCmd := item.Update(msg)
281		a.pages[a.currentPage] = updated
282
283		cmds = append(cmds, pageCmd)
284		return a, tea.Batch(cmds...)
285
286	case tea.KeyPressMsg:
287		return a, a.handleKeyPressMsg(msg)
288
289	case tea.MouseWheelMsg:
290		if a.dialog.HasDialogs() {
291			u, dialogCmd := a.dialog.Update(msg)
292			a.dialog = u.(dialogs.DialogCmp)
293			cmds = append(cmds, dialogCmd)
294		} else {
295			item, ok := a.pages[a.currentPage]
296			if !ok {
297				return a, nil
298			}
299
300			updated, pageCmd := item.Update(msg)
301			a.pages[a.currentPage] = updated
302
303			cmds = append(cmds, pageCmd)
304		}
305		return a, tea.Batch(cmds...)
306	case tea.PasteMsg:
307		if a.dialog.HasDialogs() {
308			u, dialogCmd := a.dialog.Update(msg)
309			if model, ok := u.(dialogs.DialogCmp); ok {
310				a.dialog = model
311			}
312
313			cmds = append(cmds, dialogCmd)
314		} else {
315			item, ok := a.pages[a.currentPage]
316			if !ok {
317				return a, nil
318			}
319
320			updated, pageCmd := item.Update(msg)
321			a.pages[a.currentPage] = updated
322
323			cmds = append(cmds, pageCmd)
324		}
325		return a, tea.Batch(cmds...)
326	}
327	s, _ := a.status.Update(msg)
328	a.status = s.(status.StatusCmp)
329
330	item, ok := a.pages[a.currentPage]
331	if !ok {
332		return a, nil
333	}
334
335	updated, cmd := item.Update(msg)
336	a.pages[a.currentPage] = updated
337
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	}
346	cmds = append(cmds, cmd)
347	return a, tea.Batch(cmds...)
348}
349
350// handleWindowResize processes window resize events and updates all components.
351func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
352	var cmds []tea.Cmd
353
354	// TODO: clean up these magic numbers.
355	if a.showingFullHelp {
356		height -= 5
357	} else {
358		height -= 2
359	}
360
361	a.width, a.height = width, height
362	// Update status bar
363	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
364	if model, ok := s.(status.StatusCmp); ok {
365		a.status = model
366	}
367	cmds = append(cmds, cmd)
368
369	// Update the current view.
370	for p, page := range a.pages {
371		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
372		a.pages[p] = updated
373
374		cmds = append(cmds, pageCmd)
375	}
376
377	// Update the dialogs
378	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
379	if model, ok := dialog.(dialogs.DialogCmp); ok {
380		a.dialog = model
381	}
382
383	cmds = append(cmds, cmd)
384
385	return tea.Batch(cmds...)
386}
387
388// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
389func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
390	// Check this first as the user should be able to quit no matter what.
391	if key.Matches(msg, a.keyMap.Quit) {
392		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
393			return tea.Quit
394		}
395		return util.CmdHandler(dialogs.OpenDialogMsg{
396			Model: quit.NewQuitDialog(),
397		})
398	}
399
400	if a.completions.Open() {
401		// completions
402		keyMap := a.completions.KeyMap()
403		switch {
404		case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
405			key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
406			key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
407			u, cmd := a.completions.Update(msg)
408			a.completions = u.(completions.Completions)
409			return cmd
410		}
411	}
412	if a.dialog.HasDialogs() {
413		u, dialogCmd := a.dialog.Update(msg)
414		a.dialog = u.(dialogs.DialogCmp)
415		return dialogCmd
416	}
417	switch {
418	// help
419	case key.Matches(msg, a.keyMap.Help):
420		a.status.ToggleFullHelp()
421		a.showingFullHelp = !a.showingFullHelp
422		return a.handleWindowResize(a.wWidth, a.wHeight)
423	// dialogs
424	case key.Matches(msg, a.keyMap.Commands):
425		// if the app is not configured show no commands
426		if !a.isConfigured {
427			return nil
428		}
429		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
430			return util.CmdHandler(dialogs.CloseDialogMsg{})
431		}
432		if a.dialog.HasDialogs() {
433			return nil
434		}
435		return util.CmdHandler(dialogs.OpenDialogMsg{
436			Model: commands.NewCommandDialog(a.selectedSessionID),
437		})
438	case key.Matches(msg, a.keyMap.Sessions):
439		// if the app is not configured show no sessions
440		if !a.isConfigured {
441			return nil
442		}
443		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
444			return util.CmdHandler(dialogs.CloseDialogMsg{})
445		}
446		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
447			return nil
448		}
449		var cmds []tea.Cmd
450		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
451			// If the commands dialog is open, close it first
452			cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
453		}
454		cmds = append(cmds,
455			func() tea.Msg {
456				allSessions, _ := a.app.Sessions.List(context.Background())
457				return dialogs.OpenDialogMsg{
458					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
459				}
460			},
461		)
462		return tea.Sequence(cmds...)
463	case key.Matches(msg, a.keyMap.Suspend):
464		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
465			return util.ReportWarn("Agent is busy, please wait...")
466		}
467		return tea.Suspend
468	default:
469		item, ok := a.pages[a.currentPage]
470		if !ok {
471			return nil
472		}
473
474		updated, cmd := item.Update(msg)
475		a.pages[a.currentPage] = updated
476		return cmd
477	}
478}
479
480// moveToPage handles navigation between different pages in the application.
481func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
482	if a.app.AgentCoordinator.IsBusy() {
483		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
484		return util.ReportWarn("Agent is busy, please wait...")
485	}
486
487	var cmds []tea.Cmd
488	if _, ok := a.loadedPages[pageID]; !ok {
489		cmd := a.pages[pageID].Init()
490		cmds = append(cmds, cmd)
491		a.loadedPages[pageID] = true
492	}
493	a.previousPage = a.currentPage
494	a.currentPage = pageID
495	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
496		cmd := sizable.SetSize(a.width, a.height)
497		cmds = append(cmds, cmd)
498	}
499
500	return tea.Batch(cmds...)
501}
502
503// View renders the complete application interface including pages, dialogs, and overlays.
504func (a *appModel) View() tea.View {
505	var view tea.View
506	view.AltScreen = true
507	t := styles.CurrentTheme()
508	view.BackgroundColor = t.BgBase
509	if a.wWidth < 25 || a.wHeight < 15 {
510		view.Layer = lipgloss.NewCanvas(
511			lipgloss.NewLayer(
512				t.S().Base.Width(a.wWidth).Height(a.wHeight).
513					Align(lipgloss.Center, lipgloss.Center).
514					Render(
515						t.S().Base.
516							Padding(1, 4).
517							Foreground(t.White).
518							BorderStyle(lipgloss.RoundedBorder()).
519							BorderForeground(t.Primary).
520							Render("Window too small!"),
521					),
522			),
523		)
524		return view
525	}
526
527	page := a.pages[a.currentPage]
528	if withHelp, ok := page.(core.KeyMapHelp); ok {
529		a.status.SetKeyMap(withHelp.Help())
530	}
531	pageView := page.View()
532	components := []string{
533		pageView,
534	}
535	components = append(components, a.status.View())
536
537	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
538	layers := []*lipgloss.Layer{
539		lipgloss.NewLayer(appView),
540	}
541	if a.dialog.HasDialogs() {
542		layers = append(
543			layers,
544			a.dialog.GetLayers()...,
545		)
546	}
547
548	var cursor *tea.Cursor
549	if v, ok := page.(util.Cursor); ok {
550		cursor = v.Cursor()
551		// Hide the cursor if it's positioned outside the textarea
552		statusHeight := a.height - strings.Count(pageView, "\n") + 1
553		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
554			cursor = nil
555		}
556	}
557	activeView := a.dialog.ActiveModel()
558	if activeView != nil {
559		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
560		if v, ok := activeView.(util.Cursor); ok {
561			cursor = v.Cursor()
562		}
563	}
564
565	if a.completions.Open() && cursor != nil {
566		cmp := a.completions.View()
567		x, y := a.completions.Position()
568		layers = append(
569			layers,
570			lipgloss.NewLayer(cmp).X(x).Y(y),
571		)
572	}
573
574	canvas := lipgloss.NewCanvas(
575		layers...,
576	)
577
578	view.Layer = canvas
579	view.Cursor = cursor
580	view.MouseMode = tea.MouseModeCellMotion
581
582	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
583		// HACK: use a random percentage to prevent ghostty from hiding it
584		// after a timeout.
585		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
586	}
587	return view
588}
589
590// New creates and initializes a new TUI application model.
591func New(app *app.App) *appModel {
592	chatPage := chat.New(app)
593	keyMap := DefaultKeyMap()
594	keyMap.pageBindings = chatPage.Bindings()
595
596	model := &appModel{
597		currentPage: chat.ChatPageID,
598		app:         app,
599		status:      status.NewStatusCmp(),
600		loadedPages: make(map[page.PageID]bool),
601		keyMap:      keyMap,
602
603		pages: map[page.PageID]util.Model{
604			chat.ChatPageID: chatPage,
605		},
606
607		dialog:      dialogs.NewDialogCmp(),
608		completions: completions.New(),
609	}
610
611	return model
612}