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