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