tui.go

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