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