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