tui.go

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