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}