tui.go

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