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