tui.go

  1package tui
  2
  3import (
  4	"context"
  5	"fmt"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/app"
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/llm/agent"
 12	"github.com/charmbracelet/crush/internal/permission"
 13	"github.com/charmbracelet/crush/internal/pubsub"
 14	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 15	"github.com/charmbracelet/crush/internal/tui/components/completions"
 16	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 17	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 18	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 19	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 20	"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
 21	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 22	initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
 23	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 24	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 25	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 26	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
 27	"github.com/charmbracelet/crush/internal/tui/page"
 28	"github.com/charmbracelet/crush/internal/tui/page/chat"
 29	"github.com/charmbracelet/crush/internal/tui/styles"
 30	"github.com/charmbracelet/crush/internal/tui/util"
 31	"github.com/charmbracelet/lipgloss/v2"
 32)
 33
 34// MouseEventFilter filters mouse events based on the current focus state
 35// This is used with tea.WithFilter to prevent mouse scroll events from
 36// interfering with typing performance in the editor
 37func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
 38	// Only filter mouse events
 39	switch msg.(type) {
 40	case tea.MouseWheelMsg, tea.MouseMotionMsg:
 41		// Check if we have an appModel and if editor is focused
 42		if appModel, ok := m.(*appModel); ok {
 43			if appModel.currentPage == chat.ChatPageID {
 44				if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
 45					// If editor is focused (not chatFocused), filter out mouse wheel/motion events
 46					if !chatPage.IsChatFocused() {
 47						return nil // Filter out the event
 48					}
 49				}
 50			}
 51		}
 52	}
 53	// Allow all other events to pass through
 54	return msg
 55}
 56
 57// appModel represents the main application model that manages pages, dialogs, and UI state.
 58type appModel struct {
 59	wWidth, wHeight int // Window dimensions
 60	width, height   int
 61	keyMap          KeyMap
 62
 63	currentPage  page.PageID
 64	previousPage page.PageID
 65	pages        map[page.PageID]util.Model
 66	loadedPages  map[page.PageID]bool
 67
 68	// Status
 69	status          status.StatusCmp
 70	showingFullHelp bool
 71
 72	app *app.App
 73
 74	dialog      dialogs.DialogCmp
 75	completions completions.Completions
 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	var cmds []tea.Cmd
 84	cmd := a.pages[a.currentPage].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	// Check if we should show the init dialog
 92	cmds = append(cmds, func() tea.Msg {
 93		shouldShow, err := config.ProjectNeedsInitialization()
 94		if err != nil {
 95			return util.InfoMsg{
 96				Type: util.InfoTypeError,
 97				Msg:  "Failed to check init status: " + err.Error(),
 98			}
 99		}
100		if shouldShow {
101			return dialogs.OpenDialogMsg{
102				Model: initDialog.NewInitDialogCmp(),
103			}
104		}
105		return nil
106	})
107
108	return tea.Batch(cmds...)
109}
110
111// Update handles incoming messages and updates the application state.
112func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113	var cmds []tea.Cmd
114	var cmd tea.Cmd
115
116	switch msg := msg.(type) {
117	case tea.KeyboardEnhancementsMsg:
118		return a, nil
119	case tea.WindowSizeMsg:
120		return a, a.handleWindowResize(msg.Width, msg.Height)
121
122	// Completions messages
123	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
124		u, completionCmd := a.completions.Update(msg)
125		a.completions = u.(completions.Completions)
126		return a, completionCmd
127
128	// Dialog messages
129	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
130		u, dialogCmd := a.dialog.Update(msg)
131		a.dialog = u.(dialogs.DialogCmp)
132		return a, dialogCmd
133	case commands.ShowArgumentsDialogMsg:
134		return a, util.CmdHandler(
135			dialogs.OpenDialogMsg{
136				Model: commands.NewCommandArgumentsDialog(
137					msg.CommandID,
138					msg.Content,
139					msg.ArgNames,
140				),
141			},
142		)
143	// Page change messages
144	case page.PageChangeMsg:
145		return a, a.moveToPage(msg.ID)
146
147	// Status Messages
148	case util.InfoMsg, util.ClearStatusMsg:
149		s, statusCmd := a.status.Update(msg)
150		a.status = s.(status.StatusCmp)
151		cmds = append(cmds, statusCmd)
152		return a, tea.Batch(cmds...)
153
154	// Session
155	case cmpChat.SessionSelectedMsg:
156		a.selectedSessionID = msg.ID
157	case cmpChat.SessionClearedMsg:
158		a.selectedSessionID = ""
159	// Commands
160	case commands.SwitchSessionsMsg:
161		return a, func() tea.Msg {
162			allSessions, _ := a.app.Sessions.List(context.Background())
163			return dialogs.OpenDialogMsg{
164				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
165			}
166		}
167
168	case commands.SwitchModelMsg:
169		return a, util.CmdHandler(
170			dialogs.OpenDialogMsg{
171				Model: models.NewModelDialogCmp(),
172			},
173		)
174	// Compact
175	case commands.CompactMsg:
176		return a, util.CmdHandler(dialogs.OpenDialogMsg{
177			Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
178		})
179
180	// Model Switch
181	case models.ModelSelectedMsg:
182		config.UpdatePreferredModel(msg.ModelType, msg.Model)
183
184		// Update the agent with the new model/provider configuration
185		if err := a.app.UpdateAgentModel(); err != nil {
186			return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
187		}
188
189		modelTypeName := "large"
190		if msg.ModelType == config.SelectedModelTypeSmall {
191			modelTypeName = "small"
192		}
193		return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
194
195	// File Picker
196	case chat.OpenFilePickerMsg:
197		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
198			// If the commands dialog is already open, close it
199			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
200		}
201		return a, util.CmdHandler(dialogs.OpenDialogMsg{
202			Model: filepicker.NewFilePickerCmp(),
203		})
204	// Permissions
205	case pubsub.Event[permission.PermissionRequest]:
206		return a, util.CmdHandler(dialogs.OpenDialogMsg{
207			Model: permissions.NewPermissionDialogCmp(msg.Payload),
208		})
209	case permissions.PermissionResponseMsg:
210		switch msg.Action {
211		case permissions.PermissionAllow:
212			a.app.Permissions.Grant(msg.Permission)
213		case permissions.PermissionAllowForSession:
214			a.app.Permissions.GrantPersistent(msg.Permission)
215		case permissions.PermissionDeny:
216			a.app.Permissions.Deny(msg.Permission)
217		}
218		return a, nil
219	// Agent Events
220	case pubsub.Event[agent.AgentEvent]:
221		payload := msg.Payload
222
223		// Forward agent events to dialogs
224		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
225			u, dialogCmd := a.dialog.Update(payload)
226			a.dialog = u.(dialogs.DialogCmp)
227			cmds = append(cmds, dialogCmd)
228		}
229
230		// Handle auto-compact logic
231		if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
232			// Get current session to check token usage
233			session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
234			if err == nil {
235				model := a.app.CoderAgent.Model()
236				contextWindow := model.ContextWindow
237				tokens := session.CompletionTokens + session.PromptTokens
238				if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
239					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
240						Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
241					}))
242				}
243			}
244		}
245
246		return a, tea.Batch(cmds...)
247	// Key Press Messages
248	case tea.KeyPressMsg:
249		return a, a.handleKeyPressMsg(msg)
250	}
251	s, _ := a.status.Update(msg)
252	a.status = s.(status.StatusCmp)
253	updated, cmd := a.pages[a.currentPage].Update(msg)
254	a.pages[a.currentPage] = updated.(util.Model)
255	if a.dialog.HasDialogs() {
256		u, dialogCmd := a.dialog.Update(msg)
257		a.dialog = u.(dialogs.DialogCmp)
258		cmds = append(cmds, dialogCmd)
259	}
260	cmds = append(cmds, cmd)
261	return a, tea.Batch(cmds...)
262}
263
264// handleWindowResize processes window resize events and updates all components.
265func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
266	var cmds []tea.Cmd
267	a.wWidth, a.wHeight = width, height
268	if a.showingFullHelp {
269		height -= 4
270	} else {
271		height -= 2
272	}
273	a.width, a.height = width, height
274	// Update status bar
275	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
276	a.status = s.(status.StatusCmp)
277	cmds = append(cmds, cmd)
278
279	// Update the current page
280	for p, page := range a.pages {
281		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
282		a.pages[p] = updated.(util.Model)
283		cmds = append(cmds, pageCmd)
284	}
285
286	// Update the dialogs
287	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
288	a.dialog = dialog.(dialogs.DialogCmp)
289	cmds = append(cmds, cmd)
290
291	return tea.Batch(cmds...)
292}
293
294// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
295func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
296	switch {
297	// completions
298	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
299		u, cmd := a.completions.Update(msg)
300		a.completions = u.(completions.Completions)
301		return cmd
302
303	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
304		u, cmd := a.completions.Update(msg)
305		a.completions = u.(completions.Completions)
306		return cmd
307	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
308		u, cmd := a.completions.Update(msg)
309		a.completions = u.(completions.Completions)
310		return cmd
311	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
312		u, cmd := a.completions.Update(msg)
313		a.completions = u.(completions.Completions)
314		return cmd
315		// help
316	case key.Matches(msg, a.keyMap.Help):
317		a.status.ToggleFullHelp()
318		a.showingFullHelp = !a.showingFullHelp
319		return a.handleWindowResize(a.wWidth, a.wHeight)
320	// dialogs
321	case key.Matches(msg, a.keyMap.Quit):
322		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
323			// if the quit dialog is already open, close the app
324			return tea.Quit
325		}
326		return util.CmdHandler(dialogs.OpenDialogMsg{
327			Model: quit.NewQuitDialog(),
328		})
329
330	case key.Matches(msg, a.keyMap.Commands):
331		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
332			// If the commands dialog is already open, close it
333			return util.CmdHandler(dialogs.CloseDialogMsg{})
334		}
335		return util.CmdHandler(dialogs.OpenDialogMsg{
336			Model: commands.NewCommandDialog(a.selectedSessionID),
337		})
338	case key.Matches(msg, a.keyMap.Sessions):
339		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
340			// If the sessions dialog is already open, close it
341			return util.CmdHandler(dialogs.CloseDialogMsg{})
342		}
343		var cmds []tea.Cmd
344		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
345			// If the commands dialog is open, close it first
346			cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
347		}
348		cmds = append(cmds,
349			func() tea.Msg {
350				allSessions, _ := a.app.Sessions.List(context.Background())
351				return dialogs.OpenDialogMsg{
352					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
353				}
354			},
355		)
356		return tea.Sequence(cmds...)
357	default:
358		if a.dialog.HasDialogs() {
359			u, dialogCmd := a.dialog.Update(msg)
360			a.dialog = u.(dialogs.DialogCmp)
361			return dialogCmd
362		} else {
363			updated, cmd := a.pages[a.currentPage].Update(msg)
364			a.pages[a.currentPage] = updated.(util.Model)
365			return cmd
366		}
367	}
368}
369
370// moveToPage handles navigation between different pages in the application.
371func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
372	if a.app.CoderAgent.IsBusy() {
373		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
374		return util.ReportWarn("Agent is busy, please wait...")
375	}
376
377	var cmds []tea.Cmd
378	if _, ok := a.loadedPages[pageID]; !ok {
379		cmd := a.pages[pageID].Init()
380		cmds = append(cmds, cmd)
381		a.loadedPages[pageID] = true
382	}
383	a.previousPage = a.currentPage
384	a.currentPage = pageID
385	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
386		cmd := sizable.SetSize(a.width, a.height)
387		cmds = append(cmds, cmd)
388	}
389
390	return tea.Batch(cmds...)
391}
392
393// View renders the complete application interface including pages, dialogs, and overlays.
394func (a *appModel) View() tea.View {
395	page := a.pages[a.currentPage]
396	if withHelp, ok := page.(layout.Help); ok {
397		a.keyMap.pageBindings = withHelp.Bindings()
398	}
399	a.status.SetKeyMap(a.keyMap)
400	pageView := page.View()
401	components := []string{
402		pageView,
403	}
404	components = append(components, a.status.View())
405
406	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
407	layers := []*lipgloss.Layer{
408		lipgloss.NewLayer(appView),
409	}
410	if a.dialog.HasDialogs() {
411		layers = append(
412			layers,
413			a.dialog.GetLayers()...,
414		)
415	}
416
417	var cursor *tea.Cursor
418	if v, ok := page.(util.Cursor); ok {
419		cursor = v.Cursor()
420	}
421	activeView := a.dialog.ActiveModel()
422	if activeView != nil {
423		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
424		if v, ok := activeView.(util.Cursor); ok {
425			cursor = v.Cursor()
426		}
427	}
428
429	if a.completions.Open() && cursor != nil {
430		cmp := a.completions.View()
431		x, y := a.completions.Position()
432		layers = append(
433			layers,
434			lipgloss.NewLayer(cmp).X(x).Y(y),
435		)
436	}
437
438	canvas := lipgloss.NewCanvas(
439		layers...,
440	)
441
442	var view tea.View
443	t := styles.CurrentTheme()
444	view.Layer = canvas
445	view.BackgroundColor = t.BgBase
446	view.Cursor = cursor
447	return view
448}
449
450// New creates and initializes a new TUI application model.
451func New(app *app.App) tea.Model {
452	chatPage := chat.NewChatPage(app)
453	keyMap := DefaultKeyMap()
454	keyMap.pageBindings = chatPage.Bindings()
455
456	model := &appModel{
457		currentPage: chat.ChatPageID,
458		app:         app,
459		status:      status.NewStatusCmp(keyMap),
460		loadedPages: make(map[page.PageID]bool),
461		keyMap:      keyMap,
462
463		pages: map[page.PageID]util.Model{
464			chat.ChatPageID: chatPage,
465		},
466
467		dialog:      dialogs.NewDialogCmp(),
468		completions: completions.New(),
469	}
470
471	return model
472}